Moving slow work out of the HTTP request. Emails, image resizing, PDF generation, webhook calls. Users should not stare at a spinner while PHP sends twelve emails synchronously.
When to queue
- Work takes more than a second reliably
- Failure should retry without the user resubmitting
- Traffic spikes would tie up all PHP-FPM workers
Do not queue everything. A quick INSERT belongs in the request. A “welcome” email with three attachments does not.
Job as a plain object
<?php
declare(strict_types=1);
namespace AppJobs;
final class SendWelcomeEmail
{
public function __construct(
public readonly int $userId,
public readonly string $email,
) {}
}
Serializable, explicit, easy to log. The handler knows how to run it:
final class SendWelcomeEmailHandler
{
public function __construct(private Mailer $mailer) {}
public function handle(SendWelcomeEmail $job): void
{
$this->mailer->send($job->email, 'Welcome', '...');
}
}
File-based queue (no Redis required)
For learning and small sites, a directory of JSON files works. Production often moves to Redis, database, or a managed queue – same job shape, different driver.
final class FileQueue
{
public function __construct(private string $dir) {}
public function push(object $job): void
{
$file = $this->dir . '/' . Uniqid('job_', true) . '.json';
file_put_contents($file, json_encode([
'class' => $job::class,
'payload' => get_object_vars($job),
]), LOCK_EX);
}
public function pop(): ?array
{
$files = glob($this->dir . '/*.json') ?: [];
sort($files);
if ($files === []) {
return null;
}
$file = $files[0];
$data = json_decode(file_get_contents($file), true);
unlink($file);
return $data;
}
}
The worker script
#!/usr/bin/env php
<?php
// bin/worker.php
require __DIR__ . '/../vendor/autoload.php';
$queue = new FileQueue(__DIR__ . '/../storage/queue');
$handlers = [
SendWelcomeEmail::class => new SendWelcomeEmailHandler($mailer),
];
while ($job = $queue->pop()) {
$handler = $handlers[$job['class']] ?? null;
if (!$handler) {
error_log('Unknown job: ' . $job['class']);
continue;
}
$instance = new $job['class'](...$job['payload']);
$handler->handle($instance);
}
Run via cron every minute, or use supervisor to keep a long-lived worker process. Same idea either way: pull, handle, repeat.
Dispatch from the controller
public function register(): void
{
$userId = $this->users->create($_POST);
$this->queue->push(new SendWelcomeEmail($userId, $_POST['email']));
redirect('/dashboard', 'Account created.');
}
Retries and failures
Jobs fail. Network blips, API rate limits, full mailboxes. Track attempts on the job payload; after three failures, move to a failed/ folder and alert a human. Never silently drop work you promised to do.
Idempotency matters: sending the same welcome email twice because of a retry is annoying but survivable. Charging a card twice is not – design those jobs carefully.

