popphp / pop-queue
Pop Queue Component for Pop PHP Framework
Requires
- php: >=8.4.0
- laravel/serializable-closure: ^2.0.3
- popphp/pop-console: ^5.0.0
- popphp/pop-db: ^7.0.0
- popphp/pop-utils: ^3.0.0
- popphp/popphp: ^5.0.0
- symfony/process: ^7.2|^8.0
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^12.5.0
Suggests
- ext-redis: For using the Redis adapter
- aws/aws-sdk-php: For using the Sqs adapter (^3.283.18)
README
Overview
pop-queue is a job queue component that provides the ability to pass executable jobs or tasks
off to a queue to be processed at a later date and time. Queues can either process jobs or scheduled
tasks. The jobs or tasks are stored with an available queue storage adapter until they are called to be
executed. The available storage adapters for the queue component are:
- Redis
- Database
- File
- Memory
- AWS SQS*
The difference between jobs and tasks are that jobs are "one and done" (unless they fail) and pop off the queue once complete. Tasks are persistent and remain in the queue to run repeatedly on their set schedule, or until they expire.
* - The SQS adapter does not support tasks.
pop-queue is a component of the Pop PHP Framework.
Install
Install pop-queue using Composer.
composer require popphp/pop-queue
Or, require it in your composer.json file
"require": {
"popphp/pop-queue" : "^3.0.0"
}
Requirements
pop-queue requires PHP 8.4.0 or greater. Everything else depends on which features you use:
| Requirement | Needed for | Without it |
|---|---|---|
ext-redis |
The Redis queue adapter, and the Redis registry backend | The other adapters and registry backends work normally |
aws/aws-sdk-php |
The AWS SQS adapter | The other adapters work normally |
proc_open() |
CLI command jobs | Symfony\Process throws when the job runs |
ext-pcntl |
Job timeouts on callable/command jobs, and signal-based graceful shutdown | Jobs run untimed; loops end only via stop() |
aws/aws-sdk-php is not installed automatically — install it separately if you use the SQS
adapter (see AWS SQS).
proc_open() is new in 3.0: earlier versions shelled out with exec(), which some hosts allow while
disabling proc_open(). If you're upgrading and use CLI command jobs, see
Upgrading to 3.0.
Quickstart
Create a job and push to the queue
Simply adding a job to the queue will push it to the queue storage adapter.
use Pop\Queue\Queue; use Pop\Queue\Adapter\File; use Pop\Queue\Process\Job; // Create a job and add it to a queue $job = Job::create(function() { echo 'This is job' . PHP_EOL; }); $queue = new Queue('pop-queue', new File(__DIR__ . '/queue')); $queue->addJob($job);
Call the queue to process the job
use Pop\Queue\Queue; use Pop\Queue\Worker; use Pop\Queue\Adapter\File; // Call up the queue and pass it to a worker object $queue = new Queue('pop-queue', new File(__DIR__ . '/queue')); $worker = Worker::create($queue); // Trigger the worker to work the next job across its queues $worker->workAll();
If the job is valid, it will run. In this case, it will produce this output:
This is a job
Create a scheduled task and push to the queue
Create a task, set the schedule and add it to the queue.
use Pop\Queue\Queue; use Pop\Queue\Adapter\File; use Pop\Queue\Process\Task; $task = Task::create(function() { echo 'This is a scheduled task' . PHP_EOL; })->every30Minutes(); // Add to a queue $queue = new Queue('pop-queue', new File(__DIR__ . '/queue')); $queue->addTask($task);
Call the queue to run the scheduled task
use Pop\Queue\Queue; use Pop\Queue\Worker; use Pop\Queue\Adapter\File; // Call up the queue and pass it to a worker object $queue = new Queue('pop-queue', new File(__DIR__ . '/queue')); $worker = Worker::create($queue); // Trigger the worker to run the next scheduled task across its queues $worker->runAll();
If the task is valid, it will run. In this case, it will produce this output:
This is a scheduled task
Jobs
Job objects are at the heart of the pop-queue component. They are objects that can execute
either a callable, an application command or even a CLI-based command (if the environment is
set up to allow that.)
Jobs get assigned an ID hash by default for reference.
var_dump($job->hasJobId()); $id = $job->getJobId();
As a job is picked up to be executed, there are a number of methods to assist with the status of a job during its lifecycle:
var_dump($job->hasStarted()); // Has a started timestamp var_dump($job->hasNotRun()); // No started timestamp and no completed timestamp var_dump($job->isRunning()); // Has a started timestamp, but not a completed/failed var_dump($job->isComplete()); // Has a completed timestamp var_dump($job->hasFailed()); // Has a failed timestamp var_dump($job->getStarted()); var_dump($job->getCompleted()); var_dump($job->getFailed());
Callables
Any callable object can be passed into a job object:
use Pop\Queue\Process\Job; // Create a job from a closure $job1 = Job::create(function() { echo 'This is job #1' . PHP_EOL; }); // Create a job from a static class method $job2 = Job::create('MyApp\Service\SomeService::doSomething'); // Create a job with parameters $closure = function($num) { echo 'This is job ' . $num . PHP_EOL; }; // The second argument passed is the callable's parameter(s) $job3 = Job::create($closure, 1);
If the callable needs access to the main application object, that object gets prepended to the parameters of the callable. Write the job to accept it:
use Pop\Queue\Queue; use Pop\Queue\Worker; use Pop\Queue\Adapter\File; use Pop\Queue\Process\Job; // Create a job that needs the application object $job = Job::create(function($application) { // Do something with the application }); $queue = new Queue('pop-queue', new File(__DIR__ . '/queue')); $queue->addJob($job);
Then supply the application object at the point the job is actually worked. There are two ways to do that. Pass it to the worker, which hands it down to every queue it services:
use Pop\Queue\Queue; use Pop\Queue\Worker; use Pop\Queue\Adapter\File; use Pop\Application; $application = new Application(); $queue = new Queue('pop-queue', new File(__DIR__ . '/queue')); $worker = Worker::create($queue, $application); // When the worker works the job, it will push the application object to the job $worker->workAll();
Or, if you're working a queue directly without a worker, pass it to work() (or run()) per call:
$queue = new Queue('pop-queue', new File(__DIR__ . '/queue')); $queue->work($application);
Class-Based Jobs
A closure is convenient, but work of any real size is usually better off as a class. A callable can be given as a class-string, which lets a job be an ordinary, testable class instead of a closure buried in a config file:
use Pop\Queue\Process\Job; // Call an instance method - the class is instantiated when the job is worked $job1 = Job::create('MyApp\Service\DigestService->handle', ['nick@test.com']); // Call a static method $job2 = Job::create('MyApp\Service\DigestService::handleStatic', ['nick@test.com']); // Instantiate a class, passing the parameters to its constructor - the // constructed object becomes the job's results $job3 = Job::create('MyApp\Service\DigestService', ['nick@test.com']);
The application object is prepended to the parameters exactly as it is for a closure, so write the method to accept it first:
namespace MyApp\Service; class DigestService { public function handle($application, string $to): string { // Do the work; the returned value becomes the job's results return 'sent to ' . $to; } }
Two things make this the right default for non-trivial work:
- No route is required. Unlike an application command, the class doesn't need to be registered anywhere — it just has to be autoloadable in the worker process.
- The stored payload stays small. Only the class name and the parameters are serialized, so a job like the one above is well under 1KB. Queueing a rich object instead means serializing everything it holds, which is why passing identifiers rather than whole objects is the safer habit.
If the class needs constructor dependencies that can't be expressed as parameters, resolve them inside the method from the application's service locator rather than trying to store a pre-configured object on the job.
Application Commands
An application command can be registered with a job object as well. You register the command exactly as you would type it, argument values and all. For example, if the following application command route exists:
$ ./app hello world
You would register the command with a job object like this:
use Pop\Queue\Queue; use Pop\Queue\Adapter\File; use Pop\Queue\Process\Job; // Create a job from an application command and add to the queue $job = Job::command('hello world'); $queue = new Queue('pop-queue', new File(__DIR__ . '/queue')); $queue->addJob($job);
The command is resolved by the application's router, so a route that takes parameters is invoked with real values rather than with its route definition:
// For the route 'greet <name>' $job = Job::command('greet Nick');
The string form is split on whitespace, so an argument value that itself contains spaces
can't survive it. For that, pass an argv-style array of already split segments — the same
pattern Job::exec() uses:
$job = Job::command(['notify', 'Hello there, world']);
If the route resolves to a Pop\Console\Command object, the job borrows that command's
help text (or its name) as its job description, so a queued command is identifiable in the
registry without having to describe it by hand. An explicit setJobDescription() always
wins.
Again, the worker object would need to be aware of the application object to push down to the job object that requires it:
$queue = new Queue('pop-queue', new File(__DIR__ . '/queue')); $worker = Worker::create($queue, $application);
A command that can't be resolved to a route leaves the job's results as false rather than
terminating the worker.
CLI Commands
If the environment is set up to allow executable commands from within PHP, you can register CLI-based commands with a job object like this:
use Pop\Queue\Process\Job; // Create a job from an executable CLI command $job = Job::exec('ls -la');
This runs via the shell (Symfony\Process's fromShellCommandline()), so pipes, redirects, and
chaining work exactly as they would on a command line. Because it goes through a shell, avoid
building this string from anything that isn't a fully-trusted literal.
For a command built from any value you don't fully trust, pass an array instead:
$job = Job::exec(['ls', '-la']);
This runs with no shell involved at all — each array element is passed directly to the process, so shell metacharacters in any of them are inert. Prefer this form whenever part of the command isn't a hardcoded literal.
A failing command (non-zero exit code) throws Symfony\Component\Process\Exception\ProcessFailedException,
which flows through Queue::work() like any other job failure — failed(), then release() or
bury() depending on remaining attempts. A job's setTimeout() is enforced by Symfony\Process
itself for exec jobs, which (unlike a plain exec() call) can actually terminate the underlying
child process on expiry, not just abandon it running in the background.
Attempts
By default, a job runs only once. However, if a job fails, it will be pushed back onto the queue. But you can limit how much that happens by setting the max attempts of a job.
use Pop\Queue\Process\Job; $job = Job::create(function() { echo 'This is job #1' . PHP_EOL; }); $job->setMaxAttempts(10);
If you want the job to never unregister and keep trying to execute after failure, you can
set the max attempts to 0:
$job->setMaxAttempts(0);
And you can check the number of attempts vs. the max attempts like this:
var_dump($job->hasExceededMaxAttempts());
A job can also set a retry backoff, so a failed retry isn't attempted immediately:
use Pop\Queue\Process\Job; $job = Job::create(function() { echo 'This is job #1' . PHP_EOL; }); // Wait 30 seconds before every retry $job->setBackoff(30); // Or a per-attempt schedule: 10s after the 1st failure, 30s after the 2nd, // 60s after the 3rd and every failure after that $job->setBackoff([10, 30, 60]);
NOTE: setBackoff()'s delay is honored by Memory, File, Database, and Redis — a failed
job with a backoff set won't be retried until the delay elapses, on any of those four. AWS SQS is
the one exception: its release() deletes and re-sends the message without recomputing a delay, so a
backed-off job on that adapter retries immediately regardless of setBackoff().
A job (or task) can also be dispatched with a delay, so it isn't eligible to run until later:
// Available in 60 seconds $job->delay(60); // Or at an absolute time $job->delay('2026-12-01 09:00:00');
Unlike setBackoff(), an initial delay() set before a job is first pushed is honored by every
adapter. On Memory, File, Database and Redis, reserve() skips over a job that isn't
available yet and hands back the next one that is. On AWS SQS the delay is enforced by SQS
itself: push() translates it into the message's DelaySeconds, which AWS caps at 900 seconds
(15 minutes) — a longer delay on that adapter is clamped to that maximum. Exception: AWS
doesn't allow per-message DelaySeconds on a FIFO SQS queue (it's a queue-level setting there
instead), so on a .fifo queue a job's delay() is not applied — the job becomes available
immediately, same as if no delay were set.
And a job can set an execution timeout:
// Interrupt the job if it runs longer than 30 seconds $job->setTimeout(30);
How that timeout is enforced depends on the job type. For CLI command jobs it's
enforced by Symfony\Process, which needs no extension and can actually terminate the spawned child
process. For callable and application command jobs it's a soft timeout enforced with a pcntl
alarm — so it requires the pcntl extension, and without it those jobs run untimed.
The isValid() method is also available and checks both the max attempts and the
"run until" setting (which is used more with task objects - see below.)
NOTE: The run until can be enforced on a non-scheduled job and the max attempts can be enforced on a scheduled task.
Tasks
A task object is an extension of a job object with scheduling capabilities. It has a Cron
object and supports a cron-like scheduling format. However, unlike cron, it can also support
sub-minute scheduling down to the second.
Here's an example task object where the schedule is set to every 5 minutes:
use Pop\Queue\Queue; use Pop\Queue\Adapter\File; use Pop\Queue\Process\Task; // Create a scheduled task and add to the queue $task = Task::create(function() { echo 'This is job #1' . PHP_EOL; })->every5Minutes(); $queue = new Queue('pop-queue', new File(__DIR__ . '/queue')); $queue->addTask($task);
When multiple workers share the same adapter storage (e.g. several server processes each invoking the
scheduler around the same moment), run() claims each due task before executing it, so only one worker
actually runs a given task for a given due-window - the others silently skip it. This is best-effort
deduplication via shared storage, not distributed consensus: workers whose clocks disagree by more than a
few seconds could still both run the same task. If a task's side effects can't tolerate that residual risk,
make the task itself idempotent.
A claim persists for up to 90 seconds if the task it's guarding never completes (it's never refreshed or explicitly released) - long enough to safely cover a coarse (minute-granularity) task's full due-window.
Note: task claiming requires a claimTaskRun() method on the adapter, which is a breaking change for
third-party adapters written against 2.x — see Upgrading to 3.0.
Scheduling
Here is a list of available methods to assist with setting common schedules:
everySecond()every5Seconds()every10Seconds()every15Seconds()every20Seconds()every30Seconds()seconds(mixed $seconds)everyMinute()every5Minutes()every10Minutes()every15Minutes()every20Minutes()every30Minutes()minutes(mixed $minutes)hours(mixed $hours, mixed $minutes = null)hourly(mixed $minutes = null)daily(mixed $hours, mixed $minutes = null)dailyAt(string $time)weekly(mixed $day, mixed $hours = null, mixed $minutes = null)monthly(mixed $day, mixed $hours = null, mixed $minutes = null)quarterly(mixed $hours = null, mixed $minutes = null)yearly(bool $endOfYear = false, mixed $hours = null, mixed $minutes = null)weekdays()weekends()sundays()mondays()tuesdays()wednesdays()thursdays()fridays()saturdays()between(int $start, int $end)
If there is a need for a more custom schedule value, you can schedule that directly with a cron-formatted string:
use Pop\Queue\Process\Task; $task = Task::create(function() { echo 'This is job #1' . PHP_EOL; }); // Submit a cron-formatted schedule string $task->schedule('* */2 1,15 1-4 *')
Or, you can use the non-standard format to prepend a "seconds" value to the string:
// Submit a non-standard cron-formatted schedule string // that includes a prepended "seconds" value $task->schedule('*/10 * */2 1,15 1-4 *')
The standard cron string supports 5 values for
- Minutes
- Hours
- Days of the month
- Months
- Days of the week
in the format of:
min hour dom month dow
* * * * *
To keep with that format and support a non-standard "seconds" value, that value is prepended to the string creating 6 values:
sec min hour dom month dow
* * * * * *
If a task is schedule using seconds, it will trigger the worker to process the task at the sub-minute level.
Run Until
By default, a task is set to an unlimited number of attempts and is expected to continue to execute at its scheduled time. However, a "run until" value can be set with the task object to give it an "expiration" date:
use Pop\Queue\Process\Task; $task = Task::create(function() { echo 'This is job #1' . PHP_EOL; }); // Using a valid date/time string $task->every30Minutes()->runUntil('2027-11-30 23:59:59');
It can also accept a timestamp:
// Using a valid UNIX timestamp $task->every30Minutes()->runUntil(1827619199);
The isExpired() method will evaluate if the job is beyond the "run until" value.
Also, the isValid() method will evaluate both the "run until" and max attempts settings.
NOTE: The run until can be enforced on a non-scheduled job and the max attempts can be enforced on a scheduled task.
Grace period
A worker only evaluates a task's schedule when something invokes it — a cron entry, a daemon loop, a manual run. The grace period decides how late that evaluation may be and still count as due.
By default it is -1, which disregards the seconds value entirely: a minute-granularity task is due
for the whole of its matching minute. A task scheduled with everyMinute() runs whenever the worker
is first invoked during that minute; one scheduled with dailyAt('09:30') runs on the first invocation
during the 09:30 minute. This is almost always what you want, and it is why no configuration is needed
for the common case:
use Pop\Queue\Process\Task; $task = Task::create(function() { echo 'This is job #1' . PHP_EOL; })->every30Minutes(); // due for all of :00 and :30
Running once per window is guaranteed separately, by the task-claiming described under Tasks — so a wide grace period does not mean repeated runs. A task runs at most once per scheduled window no matter how many times a worker is invoked inside it.
To bound how late a task may run, set a grace period in seconds. Here the task is due only within 10 seconds of its scheduled time, and is skipped if the worker gets to it later than that:
$task->setGracePeriod(10); // $task->gracePeriod(10) is an alias
Setting 0 is the strictest possible setting: the task is due only on the exact 00 second. Be
deliberate about this — it means a worker invoked at 09:30:01 will skip the run entirely:
$task->setGracePeriod(0);
Two things worth knowing:
- There is no catch-up. A window that passes without the worker being invoked is simply missed; nothing re-runs it later. The grace period widens the window, it does not queue a backlog.
- It does not apply to sub-minute schedules. A schedule with a seconds field (
everySecond(),every15Seconds(),seconds()) is always evaluated exactly.
getGracePeriod() returns the current setting, and hasGracePeriod() reports whether any grace is
granted at all — false only when it is exactly 0, since -1 is the loosest setting rather than
the absence of one.
Adapters
By default, there are five available adapters, but additional ones can be created. Which contract you implement depends on whether your adapter needs to support scheduled tasks as well as jobs:
- Jobs only — implement
Pop\Queue\Adapter\AdapterInterfaceand extendPop\Queue\Adapter\AbstractAdapter. This coverspush()/reserve()/release()/delete()/bury(), FIFO/FILO priority, and the dead-letter methods. (AWS SQSis the one bundled adapter at this level.) - Jobs and tasks — implement
Pop\Queue\Adapter\TaskAdapterInterfaceand extendPop\Queue\Adapter\AbstractTaskAdapter(which itself extendsAbstractAdapter). On top of the job contract, this addsschedule(),getTask()/getTasks(),updateTask(),removeTask(),clearTasks()— andclaimTaskRun(), which is what makes task deduplication across multiple workers work (see Tasks above).
Queue::addTask() type-checks the adapter against TaskAdapterInterface and throws if it doesn't
qualify, which is how the jobs-only restriction is enforced at runtime.
A job that fails and still has attempts remaining is retried (after any backoff delay on
Memory/File/Database/Redis, immediately regardless of backoff on AWS SQS — see
Attempts above); a job
that exhausts its attempts is buried instead of being silently dropped. On Memory, Redis,
Database and File, a buried job is moved to that adapter's own dead-letter store, where it can
be inspected and recovered — see each adapter's getDeadJobs()/getDeadJob()/retryDeadJob()/
deleteDeadJob(). AWS SQS is the exception: it has no client-side dead-letter store, so
burying a job there simply deletes the message (getDeadJobs() returns an empty array,
getDeadJob() returns null, and retryDeadJob()/deleteDeadJob() throw). For real dead-letter
handling on SQS, configure a native SQS redrive policy pointing at a separate dead-letter queue,
which AWS applies server-side.
Redis
The Redis adapter requires Redis to be correctly configured and running on the server, as well as
the redis extension installed with PHP:
use Pop\Queue\Adapter\Redis; $adapter = new Redis();
The Redis adapter uses localhost and port 6379 as defaults. It also manages the jobs with the
Redis server by means of a key prefix. By default, that prefix is set to pop-queue. If you would
like to use alternate values for any these, you can pass them into the constructor:
$adapter = new Redis('my.redis.server.com', 6380, 'my-queue');
The remaining constructor parameters are $priority, $leaseSeconds, $password and $context:
$adapter = new Redis('my.redis.server.com', 6380, 'my-queue', 'FILO', 30, 'my-password');
A reserved job is leased for $leaseSeconds (60 by default). If the code that reserved it never
calls delete(), release() or bury() — for example, the worker process dies — the lease
expires and the job becomes reservable again by another worker instead of being stranded. Set it
comfortably longer than your longest expected job runtime — a job that routinely outlives its own
lease will be handed to a second worker while the first is still running it.
$password is sent to the server with AUTH after connecting, for a Redis instance that requires
authentication. $context is passed straight through to the redis extension's connect() call,
which is how a TLS connection is configured:
$adapter = new Redis('my.redis.server.com', 6380, 'my-queue', null, 60, 'my-password', [ 'stream' => [ 'ssl' => [ 'verify_peer' => true, 'cafile' => '/path/to/ca.pem', 'verify_peer_name' => true ] ] ]);
Database
The database adapter requires the use of the pop-db component and a database adapter
from that component:
use Pop\Queue\Adapter\Database; use Pop\Db\Db; $db = Db::mysqlConnect([ 'database' => 'DATABASE', 'username' => 'DB_USER', 'password' => 'DB_PASS' ]); $adapter = new Database($db);
The table utilized in the database to manage the jobs default to pop_queue. If you would like
to name it something else, you can pass that into the constructor:
$adapter = new Database($db, 'my_queue_jobs');
The remaining constructor parameters are $priority and $leaseSeconds:
$adapter = new Database($db, 'my_queue_jobs', 'FILO', 30); // 30-second lease, FILO priority
A reserved job is leased for $leaseSeconds (60 by default). If the code that reserved it never
calls delete(), release() or bury() — for example, the worker process dies — the lease
expires and the job becomes reservable again by another worker instead of being stranded. Set it
comfortably longer than your longest expected job runtime — a job that routinely outlives its own
lease will be handed to a second worker while the first is still running it.
File
The file adapter only requires the location on disk where the queue data will be stored:
use Pop\Queue\Adapter\File; $adapter = new File(__DIR__ . '/queues');
The remaining constructor parameters are $priority and $leaseSeconds:
$adapter = new File(__DIR__ . '/queues', 'FILO', 30); // 30-second lease, FILO priority
A reserved job is leased for $leaseSeconds (60 by default). If the code that reserved it never
calls delete(), release() or bury() — for example, the worker process dies — the lease
expires and the job becomes reservable again by another worker instead of being stranded. Set it
comfortably longer than your longest expected job runtime — a job that routinely outlives its own
lease will be handed to a second worker while the first is still running it.
Memory
The memory adapter keeps everything in PHP arrays, for the lifetime of the current process only. Nothing is persisted, so it requires no server, no extension and no disk access:
use Pop\Queue\Adapter\Memory; $adapter = new Memory();
It is the reference implementation of the adapter contract, implementing the full job lifecycle —
delay() eligibility, retry backoff applied by release(), and lease-based crash recovery — with
no server, extension or disk access required. File, Database, and Redis implement that same
lifecycle too (with real concurrency safety, since more than one process can reserve against them
at once); Memory is simply the simplest of the four to reason about and stand up in a test. A
reserved job is leased for 60 seconds by default; if the code that reserved it never calls
delete(), release() or bury() (for example, the worker process dies), the lease expires and
the job becomes reservable again instead of being stranded. The lease length, and the queue
priority, can be passed into the constructor:
$adapter = new Memory(30, 'FILO'); // 30-second lease, FILO priority
Note the argument order: Memory takes $leaseSeconds first, ahead of $priority, while
File, Database and Redis all take $leaseSeconds after $priority (their $priority
parameter predates leasing, and new parameters could only be appended). The two orders are not
interchangeable — check the constructor signature when switching an application between adapters.
Because of all of that, it's the recommended adapter for testing — both for this component's own test suite and as a drop-in test double in an application that consumes it, where it lets you exercise queue behavior (including delay, backoff and lease expiry) without standing up Redis, a database or SQS.
Queue::fake() is a shortcut for the common case — a Memory-backed queue with no further setup:
use Pop\Queue\Queue; $queue = Queue::fake(); // same as Queue::create('pop-queue', new Memory())
It accepts the same $name/$priority you'd pass to Queue::create(), plus an optional lease length:
$queue = Queue::fake('test-queue', 'FILO', 30);
AWS SQS
The Amazon AWS SQS adapter interfaces with the AWS SQS service and requires the following credentials and access information to be obtained from the AWS administration console:
- AWS Key
- AWS Secret
- AWS Region
- AWS Version (usually
latest) - The AWS Queue URL
Make sure the correct permissions are granted to the user role attempting to access the SQS service.
aws/aws-sdk-php is not installed automatically with this package — install it separately to use this
adapter:
composer require aws/aws-sdk-php
use Pop\Queue\Adapter\Sqs; use Aws\Sqs\SqsClient; $client = new SqsClient([ 'key' => 'AWS_KEY', 'secret' => 'AWS_SECRET', ], 'region' => 'AWS_REGION', 'version' => 'AWS_VERSION' ]); $adapter = new Sqs($client, 'YOUR_AWS_QUEUE_URL');
The SQS adapter has some limitations in its behavior. It does not support scheduled tasks and can only be used for jobs. Furthermore, the AWS SQS service offers two queue types - standard and FIFO. The FIFO queue enforces a strict FIFO order and delivers a consistent behavior when pushing and popping jobs to and from the queue. The standard queue is not as strict and there may be unexpected behavior regarding the order and availability of the jobs stacked in the queue, depending on the frequency of requests.
Injecting the adapter into the queue
Once any adapter object is created, it can be passed into the queue object:
use Pop\Queue\Queue; $queue = Queue::create('pop-queue', $adapter);
Queues
As shown in the quickstart example above, the queue object acts as the go-between for jobs and the queue storage adapter. Simply adding jobs or tasks to a queue object will push them to the storage object, where they will wait until their turn is called.
As shown in the example below, multiple jobs and multiple tasks can be added to the same queue:
use Pop\Queue\Queue; use Pop\Queue\Adapter\File; use Pop\Queue\Process\Job; use Pop\Queue\Process\Task; $job1 = Job::create(function() { echo 'This is job #1' . PHP_EOL; }); $job2 = Job::create(function() { echo 'This is job #2' . PHP_EOL; }); $task1 = Task::create(function() { echo 'This is scheduled task #1' . PHP_EOL; })->every30Minutes(); $task2 = Task::create(function() { echo 'This is scheduled task #2' . PHP_EOL; })->sundays(); $queue = new Queue('pop-queue', new File(__DIR__ . '/queue'), Queue::FILO); $queue->addJobs([$job1, $job2]) ->addTasks([$task1, $task2]);
Priority
A queue can have one of two priorities:
- FIFO: First In, First Out (default)
- FILO: First In, Last Out
This simply means that with FIFO, the first job pushed in will be the first job popped off. And with FILO, the first job pushed in will be the last job popped off, as the most recently pushed job will be popped off instead.
Priority can be set as the third constructor argument of the queue, or afterwards with
setPriority():
use Pop\Queue\Queue; use Pop\Queue\Adapter\File; $queue = new Queue('pop-queue', new File(__DIR__ . '/queue'), Queue::FILO); // Or set it after the fact $queue->setPriority(Queue::FILO); $queue->setPriority('FILO'); // the constants are just these two strings
The Queue::FIFO and Queue::FILO constants are available, and every adapter also accepts a
priority directly in its own constructor (see each adapter above for its exact argument position).
Setting it on the queue delegates to the adapter, so the two are equivalent.
To read it back:
$queue->getPriority(); // 'FIFO' or 'FILO' $queue->isFifo(); // bool $queue->isFilo(); // bool
Because the same two orderings are commonly named LILO and LIFO, aliases are provided —
isLilo() is identical to isFifo(), and isLifo() is identical to isFilo().
(When you use a SQS FIFO queue, the queue priority is automatically set to FIFO)
Signed payloads
Every persistent storage adapter (File, Database, Redis, Sqs) serializes job and task objects
to persist them, and unserializes them back on read. By default, that's PHP's plain
serialize()/unserialize() — anyone who can write to the
underlying storage directly (a compromised Redis instance, SQL injection elsewhere in your app, a
writable queue directory) could otherwise plant a crafted payload and get it executed the moment a
worker unserializes it.
Pop\Queue\Process\PayloadSigner closes that gap. Call setKey() once, at application bootstrap,
before any queue or worker operation:
use Pop\Queue\Process\PayloadSigner; PayloadSigner::setKey($_ENV['QUEUE_SIGNING_KEY']);
Once a key is configured, every adapter HMAC-signs a payload before writing it and verifies that signature before ever unserializing it back — a payload that doesn't verify (tampered, or written by anything other than your own application) is treated exactly like a corrupt payload today: skipped, never unserialized, never executed.
This is opt-in - with no key configured (the default), behavior is completely unchanged from before. There's no fallback-to-unsigned read path once a key is set, by design: turning signing on mid-lifecycle means anything already queued before that point will fail verification and be skipped, so either drain your queues first or accept that any in-flight jobs from before the rollout are dropped.
Events
A queue can fire lifecycle events around job and task execution, for observability - logging, metrics,
tracing, whatever a listener wants to do. This reuses Pop\Event\Manager, the same event system
Pop\Application uses for its own lifecycle (app.init, app.route.pre, etc.), rather than a separate
mechanism:
use Pop\Event\Manager; $events = new Manager(); $events->on('queue.job.post', function($job, $queue) { echo 'Job ' . $job->getJobId() . ' completed on queue ' . $queue->getName() . PHP_EOL; }); $queue->setEvents($events);
getEvents() returns the currently-set manager (or null), events() is a bare alias for it, and
hasEvents() returns whether one has been set at all.
Where the event manager comes from: if you call $queue->setEvents(), that manager is used - full stop.
If you don't, and you pass a Pop\Application into work()/run() that has its own event manager
registered, that application's manager is used instead. If neither is set, event firing is a silent no-op.
Setting a queue-level manager completely suppresses the application fallback for that queue - it's one or
the other, never both.
Listener signatures are positional, not a single params array. Manager::trigger() passes its params
array to each listener with the keys stripped - so a listener for an event fired with
['job' => $job, 'queue' => $this] must be written function($job, $queue) { ... }, not
function($params) { ... } expecting one array argument. (Manager::trigger() also always appends a
trailing result value itself, so an extra trailing positional argument is always present too - a listener
that declares fewer parameters simply ignores it.)
The events fired:
| Event | When | Params |
|---|---|---|
queue.job.pre |
About to run a valid, reserved job | job, queue |
queue.job.post |
Job ran and completed successfully | job, queue |
queue.job.failed |
Job threw | job, queue, exception |
queue.job.buried |
Job was permanently buried (already invalid before it could run, or failed and no longer valid for retry) | job, queue, reason |
queue.task.pre |
About to run a due, claimed task | task, queue |
queue.task.post |
Task ran and completed successfully | task, queue |
queue.task.failed |
Task threw | task, queue, exception |
A listener that itself throws propagates straight out of work()/run() - it is never caught or
misattributed as the job/task having failed. One consequence worth knowing: if a queue.job.pre listener
throws, the job stays reserved but never runs (it self-heals once its lease expires and gets reclaimed); if
a queue.task.pre listener throws, that task's current due-window is already claimed and is simply skipped
- the task runs again on its next due-window as normal.
Workers
The worker object allows you to configure and manage multiple queues from one worker object. Once you've added jobs or tasks to a queue, or queues, you can add those queue objects to the worker object to manage from there. Queues are given names to assist with managing and calling them within the worker object:
use Pop\Queue\Queue; use Pop\Queue\Adapter\File; // Call up the queue and pass it to a worker object $queue1 = new Queue('pop-queue1', new File(__DIR__ . '/queue1')); $queue2 = new Queue('pop-queue2', new File(__DIR__ . '/queue2')); $worker = Worker::create([$queue1, $queue2]);
From there, you can trigger the next job of a particular queue with the work() method:
$worker->work('pop-queue1');
Or, you can trigger the next jobs of all the registered queues:
$worker->workAll();
Queue weights
Queues can be registered with a weight, so a worker servicing several queues can express that some matter more than others:
$worker = Worker::create(); $worker->addQueue($queue1, 10); // high weight $worker->addQueue($queue2, 1); // low weight
Weight defaults to 0 if never set, so registering a queue without a weight behaves exactly as
before — queues are serviced in the order they were added. getQueues(), workAll(), and
runAll() all iterate queues in weight order (highest first). This is unrelated to the FIFO/FILO
Priority setting above, which controls the order jobs are popped off within a single queue -
weight controls which queue a worker considers first, not which job.
Calling work() with no queue name tries every registered queue in weight order and returns the
first job successfully claimed - the highest-weight queue is always preferred, falling through to
lower-weight queues only when nothing is available higher up:
$job = $worker->work(); // tries $queue1 first, then $queue2
Managing the scheduled tasks is similar with the run() method:
$worker->run('pop-queue1');
Or, trigger all the next scheduled tasks of all the registered queues:
$worker->runAll();
Accessing the queues
Queues can be added one at a time with addQueue() (optionally with a weight), or
several at once with addQueues():
$worker->addQueue($queue1); $worker->addQueue($queue2, 10); // with a weight $worker->addQueues([$queue3, $queue4]);
And read back by name:
$worker->getQueue('pop-queue1'); // ?Queue - null if not registered $worker->hasQueue('pop-queue1'); // bool $worker->getWeight('pop-queue1'); // int - 0 if never weighted $worker->getQueues(); // array of all queues, in weight order
The worker also implements ArrayAccess, Countable and IteratorAggregate, and exposes queues as
magic properties — so the same collection can be reached in whichever style reads best:
// Array access, keyed by queue name $worker['pop-queue1'] = $queue1; $queue = $worker['pop-queue1']; isset($worker['pop-queue1']); unset($worker['pop-queue1']); // Property access, same thing $worker->{'pop-queue1'} = $queue1; $queue = $worker->{'pop-queue1'}; // Countable and iterable count($worker); foreach ($worker as $name => $queue) { // ... }
Iteration and getQueues() both return queues in weight order (highest first), not insertion order.
One gotcha when setting via array or property access: the offset you write to is ignored — the
queue registers under its own getName(). So this is not a rename:
$queue = new Queue('pop-queue1', $adapter); $worker['some-other-name'] = $queue; isset($worker['some-other-name']); // false isset($worker['pop-queue1']); // true - keyed by the queue's own name
If the worker was given an application object, it's available too:
$worker->getApplication(); // ?Application ($worker->application() is an alias) $worker->hasApplication(); // bool
A worker can also carry an optional label and a registry, both used for observability:
$worker->setName('billing-worker-01'); // getName() / hasName() $worker->setRegistry($registry); // getRegistry() / registry() / hasRegistry()
Daemon mode
Instead of being triggered externally (e.g. from a cron job), a worker can service its queues
continuously as a long-running loop with workLoop() and runLoop(). Each calls its non-looping
counterpart - workAll() and runAll() respectively - every iteration, forever, until stopped:
$worker->workLoop(); // runs forever, servicing jobs as they arrive
Both accept an int $sleepSeconds = 1 parameter: the loop only sleeps that many seconds between
passes when a full pass found nothing to do anywhere (every queue empty for workLoop(), nothing due
for runLoop()) - if work was found, it loops again immediately, with no sleep. A $sleepSeconds
below 0 is silently clamped to 0.
These are two separate loops - run both, in two separate OS processes, to get full daemon
behavior. workLoop() alone never runs scheduled tasks, and runLoop() alone never works jobs. To
get both, run two independent processes - two systemd units, two supervisor programs, or two
backgrounded shell invocations:
$ php worker.php workLoop & $ php worker.php runLoop &
This is a deliberate design choice, not a missing feature: runAll() can itself block for up to ~59
seconds when sub-minute tasks exist, and a single synchronous PHP process can't service jobs during
that window.
Graceful shutdown. stop() sets an internal flag that both loops check; isStopped() reads it.
When ext-pcntl is loaded, workLoop()/runLoop() also install SIGTERM/SIGINT handlers
automatically that call stop() - so kill or Ctrl-C trigger the same graceful path. Without
ext-pcntl, only calling stop() programmatically can end a loop.
State the shutdown guarantee precisely: the loop never tears down or aborts a job or task
mid-execution - the current iteration's job/task always finishes running before the loop exits.
However, a blocking call inside that job's own code (e.g. sleep()) can be interrupted early by the
signal itself - PHP's sleep() returns early, with the remaining seconds, when a signal is delivered
during it. The job's remaining PHP statements still run afterward, but code relying on a sleep()
call completing its full duration for pacing or rate-limiting should be aware shutdown can cut it
short. This applies to any blocking I/O in job code generally (sockets, stream_select(), etc.), not
just sleep().
There's also an accepted shutdown-latency limitation specific to runLoop(): a stop signal arriving
while runAll()'s own internal sub-minute-task tick loop is mid-flight (up to ~59 seconds) isn't
noticed until that call returns, since the stop flag is only checked between runLoop() iterations,
not injected into Queue::run()'s own loop.
Daemon mode makes retry configuration effectively mandatory. Under the old cron-every-minute
model, a job that always fails with default settings (maxAttempts unset = unlimited, no backoff
configured = 0-second delay) retried once per minute - self-limiting and harmless. Under workLoop(),
that same job is retried immediately, in a tight loop, at full CPU, because a reserved-then-failed job
still counts as "work was found" for the idle-backoff check (it doesn't sleep between retries).
setMaxAttempts() and/or setBackoff() should be treated as required when jobs run under
workLoop() - see Attempts above for how those are configured.
Run under a process supervisor. An uncaught exception from an adapter (e.g. a dropped Redis
connection, a database timeout, a full disk) or from a worker-level event listener propagates out of
workLoop()/runLoop() and ends the process - there is no internal retry or restart. A daemon
deployment should run under a process supervisor (systemd, supervisor, pm2, or equivalent) configured
to restart the process on exit.
Worker-level events. Like queues (see Events above), a worker can fire lifecycle events
around its loops, using the same Pop\Event\Manager:
use Pop\Event\Manager; $events = new Manager(); $events->on('worker.work_loop.idle', function($worker) { echo 'No work found this pass' . PHP_EOL; }); $worker->setEvents($events);
getEvents() returns the currently-set manager (or null), events() is a bare alias for it, and
hasEvents() returns whether one has been set at all - all three behave the same way on Worker as
they do on Queue.
The events fired:
| Event | When | Params |
|---|---|---|
worker.work_loop.tick |
After every workAll() pass inside workLoop() |
jobs, worker |
worker.work_loop.idle |
A workLoop() pass found no work anywhere, right before the backoff sleep |
worker |
worker.work_loop.shutdown |
workLoop() is about to return after being stopped |
worker |
worker.run_loop.tick |
After every runAll() pass inside runLoop() |
tasks, worker |
worker.run_loop.idle |
A runLoop() pass found nothing due anywhere, right before the backoff sleep |
worker |
worker.run_loop.shutdown |
runLoop() is about to return after being stopped |
worker |
One API asymmetry to be aware of: Queue's event resolution takes a per-call ?Application $application argument on work()/run(), so which application's event manager is used (when no
queue-level manager is set) can vary call to call. Worker's event resolution has no such per-call
override - it resolves only against whatever Application was passed into Worker's own
constructor. A user expecting Worker to behave exactly like Queue here will be surprised.
Clearing the queues
You can clear the queues in a few different ways:
$worker->clear(string $queueName)// Clear completed jobs from queue$worker->clearFailed(string $queueName)// Clear failed jobs from queue$worker->clearTasks(string $queueName)// Clear tasks from queue$worker->clearAll()// Clear completed jobs from all queues$worker->clearAllFailed()// Clear failed jobs from all queues$worker->clearAllTasks()// Clear tasks from all queues
Observability
Two separate things answer "how are my workers doing": the events every queue already fires, and the worker registry.
Metrics come from events. A queue.job.post listener has the finished job in hand, so duration,
throughput, failure rate and retry pressure are all available with no extra machinery — point them at
whatever you already run:
use Pop\Event\Manager; $events = new Manager(); $events->on('queue.job.post', function($job, $queue) { StatsD::timing("queue.{$queue->getName()}.duration_seconds", $job->getDuration()); }); $events->on('queue.job.failed', function($job, $queue, $exception) { Log::warning("job {$job->getJobId()} failed: {$exception->getMessage()}"); }); $queue->setEvents($events);
$job->getDuration() returns the run time in seconds (or null unless the job both started and
completed). started/completed are whole-second timestamps, so the resolution is whole seconds too —
most sub-second jobs report 0.
See Events for the full list and the positional-listener gotcha.
Liveness comes from the registry. Events tell you about jobs that ran; they cannot tell you a worker has silently died, because a dead worker emits nothing. The registry gives each worker process an identity and a heartbeat, in storage every process can see:
use Pop\Queue\Registry\WorkerRegistry; use Pop\Queue\Registry\Adapter\Redis as RegistryRedis; $registry = new WorkerRegistry(new RegistryRedis()); $worker->setName('billing-worker-01'); // optional label $worker->setRegistry($registry); $worker->workLoop();
That is all a worker needs. It registers on start, heartbeats each pass, records what it is working on, and
deregisters on a graceful stop. Cron-invoked workers are covered too — a single work()/workAll() call
registers on entry and deregisters on exit, so a hung cron run shows up as a stale registration.
The registry backend is configured independently of the queue adapter (Memory, File, Database and
Redis are available under Pop\Queue\Registry\Adapter), so an SQS-backed queue can still have worker
visibility, and workers on several different queue backends can report into one place.
From anywhere else — a status command, a dashboard, a health check — query it:
$registry = new WorkerRegistry(new RegistryRedis()); foreach ($registry->getWorkers() as $record) { printf("%s on %s (pid %d) - %d done, %d failed%s\n", $record->getName() ?? $record->getId(), $record->getHost(), $record->getPid(), $record->getJobsProcessed(), $record->getJobsFailed(), ($record->getCurrentJobId() !== null) ? " - running {$record->getCurrentJobId()} for {$record->getCurrentJobDuration()}s" : ' - idle' ); } $registry->countWorkers(); $registry->getStaleWorkers(); // heartbeat has gone quiet $registry->getStuckWorkers(); // quiet AND holding a job past its own timeout $registry->prune(); // reap records untouched for an hour (pass seconds to change)
Understanding "stale" vs "stuck". A worker executing a job cannot heartbeat — PHP is synchronous here, so nothing runs while the job is on the stack. That means a worker legitimately grinding through a long job looks exactly like a wedged one, if you only look at silence. So the registry records what a worker was last doing, and the two queries mean different things:
| Record state | Meaning |
|---|---|
| heartbeat fresh | Healthy |
| quiet, no current job | Wedged idle — hung in the loop itself; appears in getStaleWorkers() |
| quiet, current job started seconds ago | Almost certainly just working |
quiet, current job past its own setTimeout() |
Stuck — appears in getStuckWorkers() |
getStuckWorkers() is the one worth alerting on. Give long-running jobs a setTimeout() so the registry
has a yardstick — without one it falls back to the staleness threshold.
A worker killed with SIGKILL leaves its record behind; prune() reaps it. That residue is useful in
itself — it is the evidence of what the process was doing when it died.
Setting a registry is entirely optional: with none set, no registry code runs at all.
Configuration
If you have a CLI application that is aware of your queues and has access to them, you can use that application to be the "manager" of your queues, checking them and processing them as needed. There are two supported ways to run that manager: triggering it periodically via cron, or running it continuously as a daemon process (see Daemon mode above).
Cron. Assuming you have a CLI application that processes the queue via a command like:
$ ./app manage queue
You could set up a cron job to trigger this application every minute:
* * * * * cd /path/to/your/project && ./app manage queue
Or, if you'd like any output to be routed to /dev/null:
* * * * * cd /path/to/your/project && ./app manage queue >> /dev/null 2>&1
Daemon mode. Instead of a periodic cron trigger, workLoop()/runLoop() let that same manager
run as a long-lived process that services its queues continuously - see Daemon mode above
for the full picture, including why it takes two separate processes and what to configure before
relying on it.
Upgrading to 3.0
If your application uses Queue, Worker, Job and Task in the documented way, 3.0 is close to a
drop-in upgrade — with one exception: the task buffer was renamed and its default changed, covered
in point 6 below. Everything else on those public APIs only gained methods. The remaining breaking
changes affect custom adapters, custom JobInterface implementations, and CLI command jobs.
1. The adapter contract was rewritten. This is the largest change. Jobs are no longer popped in a
single step; they're reserved, then explicitly resolved. AdapterInterface changed as follows:
| 2.x | 3.0 |
|---|---|
pop() |
reserve(), then one of delete() (success), release() (retry) or bury() (give up) |
hasFailedJob(), getFailedJob(), hasFailedJobs(), getFailedJobs(), clearFailed() |
hasDeadJobs(), countDead(), getDeadJob(), getDeadJobs(), retryDeadJob(), deleteDeadJob(), clearDead() |
getStart(), getEnd(), getStatus() |
removed — replaced by count() |
This only affects adapters you wrote yourself. Note that Queue::clearFailed() and
Worker::clearFailed() still exist and still work — they now delegate to the adapter's clearDead() —
so application code calling those needs no change.
The rewrite also added a reservation lease to Memory, File, Database and Redis, so a job
whose worker dies is reclaimed rather than stranded. (SQS needs none — AWS's own visibility timeout
already does this server-side.) A custom adapter is responsible for its own lease handling.
2. TaskAdapterInterface gained claimTaskRun(). Any adapter implementing that interface directly
must now implement this method too. It's what lets multiple workers share one storage backend without
double-running a scheduled task — see Tasks.
3. JobInterface::setExec()/getExec() were widened to accept and return string|array rather
than string/?string, to support the shell-free array form of CLI commands. A class
implementing JobInterface directly with the old narrower signatures will fail to load until widened
to match.
4. CLI command jobs now run through Symfony\Process instead of exec(). Three consequences:
- They require
proc_open()(see Requirements). Some shared hosts disable it while leavingexec()enabled. - A command that exits non-zero now fails the job. In 2.x the exit code was never checked, so a failing command silently completed successfully. If you have CLI jobs that routinely exit non-zero without that being an error, they will now be retried and eventually buried.
setTimeout()now actually terminates the child process on expiry rather than abandoning it.
5. aws/aws-sdk-php is no longer installed automatically. If you use the AWS SQS
adapter, add it explicitly:
composer require aws/aws-sdk-php
Everyone else gets a substantially smaller install.
6. The task "buffer" is now the "grace period", and it defaults to -1 instead of 0. This is the
one change that affects ordinary application code. Rename any calls:
| 2.x | 3.0 |
|---|---|
$task->setBuffer(10) |
$task->setGracePeriod(10) |
$task->buffer(10) |
$task->gracePeriod(10) |
$task->getBuffer() |
$task->getGracePeriod() |
$task->hasBuffer() |
$task->hasGracePeriod() |
$cron->setBuffer(10) / getBuffer() / hasBuffer() |
setGracePeriod() / getGracePeriod() / hasGracePeriod() |
new Cron($schedule, 10) |
unchanged positionally; the default is now -1 |
The default change matters more than the rename. In 2.x a minute-granularity task defaulted to a
grace period of 0, meaning it was due only on the 00 second — so a worker invoked even one
second late silently skipped that run, with no error and no catch-up. Tasks now default to -1,
meaning they are due for the whole of their matching minute. Combined with claimTaskRun(), which
already guarantees a task runs at most once per scheduled window, the practical effect is that
scheduled tasks fire reliably instead of depending on sub-second invocation timing. If you were
relying on the old strict behavior, set setGracePeriod(0) explicitly.
Two notes on upgrading:
hasGracePeriod()is not a rename-only change ofhasBuffer(). It now returnsfalseonly for a grace period of exactly0;-1is the loosest setting there is, not the absence of one.- Tasks already persisted in a
File,DatabaseorRedisqueue were serialized under the old property name. They deserialize fine and adopt the new-1default, but each will emit aCreation of dynamic propertydeprecation notice until it is re-scheduled. Clearing and re-scheduling your tasks after upgrading avoids the noise.