Official PHP SDK for Spooled Cloud - a modern job queue service for distributed applications.
Live Demo (SpriteForge) • Documentation
- Full REST API support - Jobs, queues, workers, schedules, workflows, webhooks, and more
- Worker runtime - Process jobs with lease renewal, lease fencing, events, and graceful shutdown
- Realtime events - SSE and WebSocket clients for live job/queue events
- Optional gRPC transport - High-performance binary protocol when extensions are available
- Retry & circuit breaker - Built-in resilience with exponential backoff and circuit breaker
- Type-safe - Full PHP 8.2+ type hints and readonly DTOs
- PSR-compliant - Works with any PSR-compatible HTTP client and logger
- Framework agnostic - Use with Laravel, Symfony, or vanilla PHP
- Webhook ingestion - Ingest custom webhooks into jobs; verify GitHub and Stripe signatures
- Dead Letter Queue (DLQ) - Manage and retry failed jobs
- Billing integration - Stripe-powered subscription management
- Automatic case conversion - Between camelCase (PHP) and snake_case (API)
- PHP 8.2 or higher
- Composer
ext-json(usually bundled)
ext-pcntl+ext-posix- Required bySpooledWorkerat startup for automatic lease renewal while synchronous handlers run (Unix-like CLI environments); REST/gRPC/realtime clients do not require themext-grpc+ext-protobuf- For gRPC transport support- WebSocket library (e.g.,
ratchet/pawl) - For WebSocket realtime support
composer require spooled-cloud/spooled# Install PHP extensions first
pecl install grpc protobuf
# Then require gRPC packages
composer require grpc/grpc google/protobuf<?php
use Spooled\SpooledClient;
use Spooled\Config\ClientOptions;
// Create client with API key
$client = new SpooledClient(
new ClientOptions(apiKey: 'sp_live_your_api_key')
);
// Create a job
$job = $client->jobs->create([
'queue' => 'email-notifications',
'payload' => [
'to' => 'user@example.com',
'subject' => 'Welcome!',
'body' => 'Thanks for signing up.',
],
'priority' => 5,
'maxRetries' => 3,
]);
echo "Created job: {$job->id}\n";
// Get job status
$status = $client->jobs->get($job->id);
echo "Status: {$status->status}\n";| Guide | Description |
|---|---|
| Getting Started | Installation, setup, and first job |
| Configuration | All configuration options, retry, and circuit breaker |
| Workers | SpooledWorker runtime, concurrency, and graceful shutdown |
| Workflows | DAG workflows with job dependencies |
| gRPC | High-performance gRPC transport |
| Resources | Complete API reference for all resources |
| Publishing | Publishing to Packagist |
See the examples/ directory for runnable code:
| Example | Description |
|---|---|
basic-usage.php |
Basic SDK usage |
worker-example.php |
Processing jobs with SpooledWorker |
workflow-example.php |
Complex workflows with dependencies |
scheduled-jobs.php |
Cron schedules |
grpc-example.php |
High-performance gRPC transport |
realtime-example.php |
SSE/WebSocket event streaming |
webhook-ingestion-example.php |
Webhook validation and ingestion |
error-handling.php |
Error handling patterns and retry logic |
If you want 5 copy/paste “real life” setups (Stripe → jobs, GitHub Actions → jobs, cron schedules, CSV import, website signup), see:
https://github.com/spooled-cloud/spooled-backend/blob/main/docs/guides/real-world-examples.md
Jobs are units of work with payloads, priorities, and retry policies:
<?php
$job = $client->jobs->create([
'queue' => 'my-queue',
'payload' => ['data' => 'value'],
'priority' => 5, // -100 to 100
'maxRetries' => 3,
'timeoutSeconds' => 300,
'scheduledFor' => '2024-01-15T10:00:00Z',
'idempotencyKey' => 'unique-key',
]);
// Get job status
$job = $client->jobs->get($job->id);
// List jobs (all filters optional, combined with AND)
$jobs = $client->jobs->list([
'queue' => 'my-queue',
'status' => ['failed', 'deadletter'], // one status, a comma list, or a list
'q' => 'invoice', // id prefix, or text in the queue name / payload.job_type
'jobType' => 'send-email', // exact payload.job_type
'createdAfter' => new DateTimeImmutable('-1 day'), // RFC 3339 string or DateTimeInterface
'createdBefore' => '2026-10-12T00:00:00Z',
'tag' => 'billing', // a single tag
]);
// A created_after/created_before that is not RFC 3339 is a ValidationError (400).
// A job's lifecycle events, oldest first ('order' => 'desc' for newest first)
$history = $client->jobs->history($job->id, ['limit' => 100]);
foreach ($history->events as $event) {
echo "{$event->createdAt} {$event->eventType} {$event->workerId}\n";
}
// Cancel a job (DELETE /jobs/{id}): only pending or scheduled jobs can be cancelled;
// a job a worker is already processing is not interrupted
$client->jobs->cancel($job->id);
// Boost priority
$client->jobs->boostPriority($job->id, 10);
// Bulk enqueue (one queue per batch: pass it once, or name the same queue on every job)
$result = $client->jobs->bulkEnqueue([
'queue' => 'my-queue',
'jobs' => [
['payload' => ['item' => 1]],
['payload' => ['item' => 2]],
],
]);
// Paginate: hasMore comes from the API's X-Has-More header
$offset = 0;
do {
$page = $client->jobs->list(['queue' => 'my-queue', 'limit' => 50, 'offset' => $offset]);
$offset += count($page->jobs);
} while ($page->hasMore);Workers settle jobs they claimed with jobs->complete(), jobs->fail() and
extend them with jobs->heartbeat(). Each call needs the claimed job's
leaseId (the API answers 422 without it); SpooledWorker does this for you:
<?php
$claimed = $client->jobs->claim(['queue' => 'my-queue', 'workerId' => $workerId, 'limit' => 1]);
foreach ($claimed->jobs as $job) {
$client->jobs->complete($job->id, [
'workerId' => $workerId,
'leaseId' => $job->leaseId,
'result' => ['ok' => true],
]);
}Process jobs with the built-in worker runtime. SpooledWorker uses short-lived
pcntl renewal processes so blocking synchronous handlers keep their leases
without requiring an event loop; install ext-pcntl and ext-posix in the CLI
environment.
<?php
use Spooled\SpooledClient;
use Spooled\Config\ClientOptions;
use Spooled\Worker\SpooledWorker;
use Spooled\Worker\JobContext;
$client = new SpooledClient(
new ClientOptions(apiKey: 'sp_live_your_api_key')
);
$worker = new SpooledWorker($client, [
'queueName' => 'my-queue',
// Handlers run one at a time in this process (one job claimed per poll);
// run several worker processes for parallelism.
// Optional, but recommended: a stable id (1-128 chars, [A-Za-z0-9._-])
// makes registration an upsert, so restarts reuse this worker's row
// instead of leaving the old one against the plan worker cap.
'workerId' => 'my-queue-worker-01',
]);
$worker->process(function (JobContext $ctx): array {
echo "Processing job {$ctx->jobId}\n";
// Access payload
$data = $ctx->get('data');
// A stop was requested: the job may still finish (its result is sent),
// but wrapping up early keeps it inside `shutdownTimeout`.
if ($ctx->isShuttingDown()) {
return ['partial' => true];
}
// Return result (job completed on success)
return ['success' => true];
});
// Graceful shutdown: stop() only requests it and returns immediately, so it is
// safe in a signal handler. The running job finishes and is acknowledged, then
// the worker deregisters and start() returns.
pcntl_async_signals(true);
pcntl_signal(SIGTERM, fn() => $worker->stop());
pcntl_signal(SIGINT, fn() => $worker->stop());
// Start processing (blocking)
$worker->start();While a handler runs, the worker's renewal process also keeps the job lease and
the worker heartbeat alive, enforces the job's timeout_seconds (the handler is
aborted and the job failed with Job timeout exceeded), and, after stop(),
aborts a handler still running after shutdownTimeout (job failed with Worker shutdown timeout). See docs/workers.md.
Registered workers report what they did: $client->workers->get($id) and
workers->list() carry jobsProcessed (jobs completed) and jobsFailed
(failures reported: each retry it caused and each dead-letter).
Orchestrate multiple jobs with dependencies:
<?php
$workflow = $client->workflows->create([
'name' => 'ETL Pipeline',
'jobs' => [
['key' => 'extract', 'queue' => 'etl', 'payload' => ['step' => 'extract']],
['key' => 'transform', 'queue' => 'etl', 'payload' => ['step' => 'transform'], 'dependsOn' => ['extract']],
['key' => 'load', 'queue' => 'etl', 'payload' => ['step' => 'load'], 'dependsOn' => ['transform']],
],
]);
// Get workflow status
$workflow = $client->workflows->get($workflow->id);
echo "Progress: {$workflow->completedJobs}/{$workflow->totalJobs}\n";
// List workflow jobs
$jobs = $client->workflows->jobs->list($workflow->id);
// Cancel workflow
$client->workflows->cancel($workflow->id);Run jobs on a cron schedule:
<?php
$schedule = $client->schedules->create([
'name' => 'Daily Report',
'queue' => 'reports',
'schedule' => '0 9 * * *', // 5-field cron
'payload' => ['type' => 'daily'],
'timezone' => 'America/New_York',
]);
// List schedules
$schedules = $client->schedules->list();
// Pause/resume
$client->schedules->pause($schedule->id);
$client->schedules->resume($schedule->id);
// Trigger immediately
$job = $client->schedules->trigger($schedule->id);
// Get execution history
$runs = $client->schedules->history($schedule->id);
// Delete schedule
$client->schedules->delete($schedule->id);Subscribe to real-time job events via SSE or WebSocket:
<?php
use Spooled\SpooledClient;
use Spooled\Config\ClientOptions;
$client = new SpooledClient(new ClientOptions(
apiKey: 'sp_live_your_api_key',
));
// Get unified realtime client (auto-selects best transport). It has its own
// credentials: SSE sends the API key, the WebSocket (JWT only) gets a JWT that
// is exchanged lazily and refreshed before it expires. REST calls keep using
// the API key either way.
$realtime = $client->realtime();
// Check available transport
if ($realtime->isWebSocketAvailable()) {
echo "Using WebSocket\n";
} else {
echo "Using SSE\n";
}
// Subscribe to queue events. Event data comes as the server sends it, with
// snake_case keys (job_id, queue_name, ...).
$realtime->subscribeToQueue('my-queue', function (array $event): void {
echo "Event: {$event['type']} - Job: {$event['data']['job_id']}\n";
});
// Or subscribe to specific job
$realtime->subscribeToJob($jobId, function (array $event): void {
echo "Job event: {$event['type']}\n";
});
// Stop when done
$realtime->stop();
$client->close();<?php
use Spooled\Realtime\SseClient;
$sse = new SseClient(
baseUrl: 'https://api.spooled.cloud',
apiKey: 'sp_live_your_api_key',
);
// Register event handlers
$sse->on('job.created', fn($e) => print("New job!\n"));
$sse->on('job.completed', fn($e) => print("Job done!\n"));
// Subscribe to all events
$sse->subscribe(function (array $event): void {
echo "[{$event['type']}] " . json_encode($event['data']) . "\n";
});
// Start listening (blocking)
$sse->listen();Manage your organization and track usage:
<?php
// Get current usage and limits
$usage = $client->organizations->getUsage();
echo "Plan: {$usage->plan}\n";
// Generate a unique slug for a new organization
$slug = $client->organizations->generateSlug('My Company');
// Check if a slug is available
$result = $client->organizations->checkSlug('my-company');
echo $result['available'] ? 'Available' : 'Taken';
// List organizations
$orgs = $client->organizations->list();
// Webhook token management (the token authenticates incoming custom webhooks)
$token = $client->organizations->getWebhookToken();
$client->organizations->regenerateWebhookToken();
// There is no way to clear a token: clearWebhookToken() is deprecated and throws
// UnsupportedOperationError without a network call. Rotate it instead.Configure outgoing webhooks for job events:
<?php
use Spooled\Types\WebhookEvent;
use Spooled\Types\WebhookFormat;
// Create webhook
$webhook = $client->webhooks->create([
'name' => 'My Webhook',
'url' => 'https://your-app.com/webhooks/spooled',
'events' => ['job.completed', 'job.failed', WebhookEvent::JOB_DEADLETTERED],
'secret' => 'hmac-secret',
]);
// Post straight into a chat channel: format slack, discord or teams turns the
// event into the message that service's incoming webhook URL accepts
$alerts = $client->webhooks->create([
'name' => 'Failures to Slack',
'url' => 'https://hooks.slack.com/services/...',
'events' => [WebhookEvent::JOB_DEADLETTERED, WebhookEvent::WORKFLOW_FAILED],
'format' => WebhookFormat::SLACK, // or 'slack'; default 'json'
]);
echo $alerts->format; // "slack"
// Test webhook
$client->webhooks->test($webhook->id);
// Get delivery history (retained for the plan's window, see below)
$deliveries = $client->webhooks->getDeliveries($webhook->id);
// Retry a failed delivery
$client->webhooks->retryDelivery($webhook->id, $deliveryId);
// Delete webhook
$client->webhooks->delete($webhook->id);Events: job.created, job.started, job.completed, job.failed,
job.cancelled, job.deadlettered, job.retried, queue.paused,
queue.resumed, worker.registered, worker.deregistered,
schedule.triggered, workflow.completed and workflow.failed
(WebhookEvent has a case for each). format is json (the signed JSON
envelope, the default), slack, discord or teams. An unknown event or
format is a ValidationError (400). Read back, Webhook::$events and
Webhook::$format are plain strings, so values the API adds later still parse.
On update, secret is three-state. Omit the key to keep the current secret,
pass a string to replace it, and pass null to clear it. Clearing means
deliveries go out unsigned, with no X-Spooled-Signature header, and a receiver
that verifies signatures will reject every one of them. Send only the keys you
mean to change; serialising a whole webhook back with null defaults now wipes
the secret.
$client->webhooks->update($webhook->id, ['secret' => null]); // stops signingAfter 20 consecutive failed deliveries a webhook is disabled automatically:
enabled becomes false and lastStatus becomes "auto_disabled", and it
receives no events until it is enabled again. failureCount counts consecutive
failed deliveries rather than individual retry attempts, and any success resets
it to 0.
if ($webhook->lastStatus === 'auto_disabled') {
// Fix the endpoint first. Re-enabling is charged against the plan webhook
// cap, so this can throw RateLimitError with errorCode "QUOTA_EXCEEDED".
$client->webhooks->enable($webhook->id);
}Delivery history is retained, not permanent: rows are removed once past the plan's history retention window (free 1 day, starter 7, pro 30, enterprise 90), and only the newest 100 deliveries per webhook are readable. Copy anything you need to keep into your own store; once a row is swept it can no longer be retried.
Manage jobs that have exhausted all retries:
<?php
// List DLQ jobs
$dlqJobs = $client->jobs->dlq->list(['limit' => 100]);
// Retry specific jobs from DLQ
$result = $client->jobs->dlq->retry([
'jobIds' => ['job-1', 'job-2'],
]);
// Retry jobs by queue ('queue' and 'queueName' are the same filter; without
// one, the operation applies to EVERY queue of the organization)
$result = $client->jobs->dlq->retry([
'queue' => 'my-queue',
'limit' => 50,
]);
// Purge DLQ (requires confirmation)
$result = $client->jobs->dlq->purge([
'queue' => 'my-queue',
'confirm' => true,
]);Manage API keys programmatically:
<?php
// Create a new API key
$apiKey = $client->apiKeys->create([
'name' => 'Production Worker',
]);
echo "Save this key: {$apiKey->key}\n"; // Only shown once!
// List all API keys
$keys = $client->apiKeys->list();
// Update key
$client->apiKeys->update($keyId, ['name' => 'Updated Name']);
// Revoke a key
$client->apiKeys->delete($keyId);$key->lastUsedAt is coarse. The API writes it at most once per key per five
minutes, not once per request, so it can be up to five minutes behind. Use it to
answer "has this key been used lately", not "did that request just happen", and
give rotation tooling a margin wider than the write interval before treating a
key as unused.
Manage billing via Stripe integration:
<?php
// Get billing status
$status = $client->billing->getStatus();
echo "Plan: {$status->planTier}\n";
// Create customer portal session
$portal = $client->billing->createPortal([
'returnUrl' => 'https://your-app.com/settings',
]);
// Redirect user to: $portal->urlThe API ingests one kind of webhook: POST /webhooks/{org_id}/custom, which
creates a job and must carry the organization's webhook token. GitHub and Stripe
events are not ingested directly: verify their signature in your own endpoint
(see the helpers below) and forward the event as a custom webhook.
<?php
// Ingest custom webhook (creates a job)
$result = $client->ingest->custom(
$orgId,
[
'queueName' => 'webhooks',
'eventType' => 'user.created',
'payload' => ['userId' => '123', 'email' => 'user@example.com'],
],
['webhookToken' => $webhookToken], // from organizations->getWebhookToken()
);
echo "Created job: {$result->jobId}\n";
// The inbound log: requests the API accepted, newest first (no payloads or
// signatures). Filters: status (created|duplicate), since, queue, limit, offset.
$log = $client->ingest->listDeliveries(['status' => 'created', 'since' => new DateTimeImmutable('-1 hour')]);
foreach ($log->deliveries as $delivery) {
echo "{$delivery->createdAt} {$delivery->eventType} -> {$delivery->matchedQueue} job {$delivery->jobId}\n";
}The inbound log is organization-wide, so it needs an API key that is not
restricted to some queues (a queue-scoped key gets an AuthorizationError).
ingest->github() and ingest->stripe() are deprecated: the API has no such
routes (they always 404'd), so they now throw UnsupportedOperationError
without a network call.
<?php
// Generate signatures (for testing)
$githubSig = $client->ingest->generateGitHubSignature($payload, $secret);
$stripeSig = $client->ingest->generateStripeSignature($payload, $secret);
// Validate signatures manually
$valid = $client->ingest->validateGitHubSignature($payload, $signature, $secret);
$valid = $client->ingest->validateStripeSignature($payload, $signature, $secret, tolerance: 300);
// Example webhook endpoint handler
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
if (!$client->ingest->validateGitHubSignature($payload, $signature, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
// Process webhook...<?php
use Spooled\SpooledClient;
use Spooled\Config\ClientOptions;
use Spooled\Config\RetryConfig;
use Spooled\Config\CircuitBreakerConfig;
$client = new SpooledClient(new ClientOptions(
// Authentication (one of these)
apiKey: 'sp_live_...',
// accessToken: 'jwt-token',
// adminKey: 'admin-key',
// URLs
baseUrl: 'https://api.spooled.cloud',
// grpcAddress: 'grpc.spooled.cloud:443',
// Timeouts (seconds)
connectTimeout: 10.0,
requestTimeout: 30.0,
// Retry configuration
retry: new RetryConfig(
maxRetries: 3,
baseDelay: 1.0, // seconds
maxDelay: 30.0, // seconds
factor: 2.0,
jitter: 0.1,
),
// Circuit breaker
circuitBreaker: new CircuitBreakerConfig(
enabled: true,
failureThreshold: 5,
successThreshold: 2,
timeout: 30.0, // seconds
),
// Custom headers
headers: [
'X-Custom-Header' => 'value',
],
// PSR-3 logger
logger: $myLogger,
));All errors extend SpooledError with specific subclasses:
<?php
use Spooled\Errors\SpooledError;
use Spooled\Errors\AuthenticationError;
use Spooled\Errors\NotFoundError;
use Spooled\Errors\RateLimitError;
use Spooled\Errors\ValidationError;
try {
$job = $client->jobs->get('non-existent-id');
} catch (NotFoundError $e) {
echo "Job not found: {$e->getMessage()}\n";
} catch (RateLimitError $e) {
echo "Rate limited. Retry after: {$e->getRetryAfterSeconds()} seconds\n";
} catch (ValidationError $e) {
echo "Validation failed: {$e->getMessage()}\n";
foreach ($e->getFieldErrors() as $field => $errors) {
echo " {$field}: " . implode(', ', $errors) . "\n";
}
} catch (AuthenticationError $e) {
echo "Invalid API key\n";
} catch (SpooledError $e) {
echo "Error [{$e->statusCode}]: {$e->getMessage()}\n";
echo "Request ID: {$e->requestId}\n";
}For high-throughput workers, use the gRPC API:
<?php
use Spooled\SpooledClient;
use Spooled\Config\ClientOptions;
// Requires ext-grpc and ext-protobuf
$client = new SpooledClient(new ClientOptions(
apiKey: 'sp_live_your_api_key',
grpcAddress: 'grpc.spooled.cloud:443',
));
// Get gRPC client (lazy-initialized)
$grpc = $client->grpc();
// Wait for connection
$grpc->waitForReady();
// Enqueue via gRPC (higher throughput than REST)
$result = $grpc->queue->enqueue([
'queueName' => 'fast-jobs',
'payload' => ['data' => 'value'],
'priority' => 5,
]);
echo "Created job: {$result['jobId']}\n";
// Get queue stats
$stats = $grpc->queue->getStats(['queueName' => 'fast-jobs']);
// For manual dequeue loops, keep the returned leaseId and send it on
// complete(), fail(), and renewLease(): the server rejects a complete or fail
// without it (INVALID_ARGUMENT). renewLease uses extensionSecs.
// dequeue() takes leaseDurationSecs (5-3600; omitted or 0 = the server default, 300 s
// on Spooled Cloud). A job that can outlive its lease needs a longer lease or a
// renewLease() call before the lease expires.
$dequeued = $grpc->queue->dequeue(['queueName' => 'fast-jobs', 'workerId' => $workerId, 'batchSize' => 1]);
foreach ($dequeued['jobs'] as $job) {
$grpc->queue->fail([
'jobId' => $job['id'],
'workerId' => $workerId,
'leaseId' => $job['leaseId'],
'error' => 'bad input',
'retry' => false, // dead-letter now; omit to retry per the job's max_retries
]);
}
// Register worker. A stable workerId (1-128 chars, [A-Za-z0-9._-]) makes a
// re-registration after a restart update the same worker instead of adding one.
$worker = $grpc->workers->register([
'queueName' => 'fast-jobs',
'hostname' => gethostname(),
'concurrency' => 10,
'workerId' => 'fast-jobs-worker-01',
]);
// Send heartbeat
$grpc->workers->heartbeat([
'workerId' => $worker['workerId'],
'currentJobs' => 0,
]);
// Deregister when done
$grpc->workers->deregister(['workerId' => $worker['workerId']]);
// Clean up connections
$client->close();gRPC carries payload and result as google.protobuf.Struct. A payload must
therefore be a JSON object: a list or scalar payload does not arrive as sent
(the server gets an empty or re-keyed object), and integers beyond 2^53 lose
precision. REST ($client->jobs->create()) keeps such payloads exactly.
<?php
use Spooled\Grpc\SpooledGrpcClient;
use Spooled\Grpc\GrpcOptions;
$grpc = new SpooledGrpcClient(new GrpcOptions(
address: 'grpc.spooled.cloud:443',
apiKey: 'sp_live_your_api_key',
secure: true,
));
$grpc->waitForReady();
// ... use $grpc->queue and $grpc->workers
$grpc->close();All operations automatically enforce tier-based limits:
| Tier | Active Jobs | Daily Jobs | Queues | Workers | Webhooks |
|---|---|---|---|---|---|
| Free | 10 | 1,000 | 2 | 1 | 1 |
| Starter | 500 | 10,000 | 10 | 5 | 5 |
| Pro | 5,000 | 100,000 | 50 | 25 | 20 |
| Enterprise | Unlimited | Unlimited | Unlimited | Unlimited | Unlimited |
(Source: the backend's config/plans.rs. Workflows: Free none, Starter 5, Pro 25.)
When a plan quota or limit is exceeded, you'll receive an HTTP 429 (a RateLimitError)
with errorCode "QUOTA_EXCEEDED" and details describing the resource, current, limit, and plan:
<?php
use Spooled\Errors\RateLimitError;
try {
$client->jobs->create([/* ... */]);
} catch (RateLimitError $e) {
if ($e->errorCode === 'QUOTA_EXCEEDED') {
echo "Plan quota exceeded: {$e->getMessage()}\n";
} else {
// Request-rate limiting also returns 429, with Retry-After and the
// X-RateLimit-* headers ($e->limit, $e->remaining, $e->reset in Unix
// seconds, $e->scope). The plan limit is per organization in requests per
// second; an API key's own rateLimit is requests per minute.
echo "Rate limited. Retry after: {$e->getRetryAfterSeconds()} seconds\n";
}
}The API sends X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
(Unix time in seconds) and X-RateLimit-Scope on its responses, not only on a
429. The client keeps the latest values:
<?php
$client->jobs->list(['limit' => 1]);
$rateLimit = $client->getLastRateLimit(); // RateLimitInfo|null
if ($rateLimit !== null && $rateLimit->remaining === 0) {
sleep($rateLimit->secondsUntilReset() ?? 1);
}A quota 429 does not clear by waiting, so the client does not retry it ($e->isQuotaExceeded()
tells the two apart) and it never counts toward the circuit breaker; a worker's
complete/fail calls keep working while enqueues hit the cap.
Idempotent requests (GET, PUT, DELETE) are retried on network errors, timeouts,
408, 429 (with Retry-After) and 5xx, up to RetryConfig::$maxRetries. A plain
POST is not retried, because a lost response would run it twice: that is why
jobs->create() is only retried when you pass an idempotencyKey. Worker acks
(jobs->complete(), jobs->fail(), jobs->heartbeat(), workers->heartbeat())
are retried too: they are fenced by the lease. If the first attempt of a
complete actually landed, the retry is answered with 409 Job is already completed
(a ConflictError).
Release 1.4.0 is defined once in Spooled\Version::VERSION. The default HTTP User-Agent and default SpooledWorker registration version derive from that constant. Application metadata may override the worker version explicitly.
# Install dependencies
composer install
# Run tests
composer test
# Run with coverage
XDEBUG_MODE=coverage composer test:coverage
# Static analysis
composer analyse
# Code formatting
composer format
# All CI checks
composer ciThe SDK includes parity test scripts that match the Node.js and Python SDK test suites:
# Run local tests (requires running backend)
API_KEY=sp_test_... BASE_URL=http://localhost:8080 composer scripts:test-local
# Run production tests: use a key of a dedicated test organization
API_KEY=sp_live_... composer scripts:test-production
# Interactive verification
composer scripts:verify-productionBoth scripts name everything they create (queues, schedules, workflows) after
the run's prefix, test-<unix time>-<8 hex>-. The clean-ups cancel jobs,
cancel workflows and delete schedules only when they carry such a name (for a
schedule, both its name and its queue), so the organization's other jobs,
schedules and workflows, including ones whose names merely start with test-,
are never touched. Tests with side effects beyond that (organization sign-up,
an email login start, a billing portal session, regenerating the inbound
webhook token) run only with RUN_UNSAFE_TESTS=1, which test-production
always turns off. Other settings: SKIP_GRPC, SKIP_STRESS, VERBOSE,
METRICS_TOKEN (for /metrics), ADMIN_API_KEY and TEST_RESULTS_FILE.
Apache License 2.0 - see LICENSE for details.