Introduction
Move slow operations out of the request cycle.
Key Concepts
- Message Queues: Decouple time-consuming work from the request cycle (e.g., RabbitMQ, Redis queues, Amazon SQS).
- Background Workers: Long-running PHP processes (Symfony Messenger, Laravel Queue) that consume and process queued jobs.
- Event-Driven Architecture: Publishing events (e.g.,
UserRegistered) that trigger asynchronous handlers for emails, analytics, etc. - Fire and Forget: Queueing work and returning an immediate response to the user, processing the work asynchronously.
Real World Context
Every major PHP application uses async processing. When a user places an order, you immediately return a confirmation while background workers handle payment processing, inventory updates, email notifications, and analytics tracking. This keeps response times under 200ms regardless of how much work the order triggers.
Deep Dive
Intro
Move slow operations out of the request cycle.
Message queues
php<?php // Instead of processing immediately: // POST /orders -> process payment -> send email -> respond // Queue work for later: // POST /orders -> queue job -> respond immediately class QueueClient { private Redis $redis; public function push(string $queue, array $job): void { $this->redis->rPush($queue, json_encode([ 'id' => uniqid(), 'payload' => $job, 'created_at' => time(), ])); } public function pop(string $queue, int $timeout = 0): ?array { $result = $this->redis->blPop($queue, $timeout); if ($result) { return json_decode($result[1], true); } return null; } } // In request handler $queue->push('emails', [ 'type' => 'order_confirmation', 'user_id' => $user->id, 'order_id' => $order->id, ]); return new JsonResponse(['order_id' => $order->id]);
Worker process
php<?php // worker.php - runs continuously class Worker { public function __construct( private QueueClient $queue, private array $handlers ) {} public function run(string $queueName): never { echo "Worker started on queue: $queueName\n"; while (true) { $job = $this->queue->pop($queueName, timeout: 30); if ($job === null) { continue; // Timeout, check again } try { $this->process($job); echo "Processed job: {$job['id']}\n"; } catch (Throwable $e) { echo "Failed job: {$job['id']} - {$e->getMessage()}\n"; $this->handleFailure($job, $e); } } } private function process(array $job): void { $type = $job['payload']['type']; if (!isset($this->handlers[$type])) { throw new RuntimeException("Unknown job type: $type"); } $this->handlers[$type]->handle($job['payload']); } private function handleFailure(array $job, Throwable $e): void { // Move to failed queue or retry $this->queue->push('failed', [ ...$job, 'error' => $e->getMessage(), 'failed_at' => time(), ]); } } // Run workers $worker = new Worker($queue, [ 'order_confirmation' => new SendOrderConfirmation(), 'process_payment' => new ProcessPayment(), 'generate_report' => new GenerateReport(), ]); $worker->run('default');
Supervisor configuration
ini; /etc/supervisor/conf.d/worker.conf [program:php-worker] command=php /var/www/app/worker.php process_name=%(program_name)s_%(process_num)02d numprocs=4 ; Run 4 worker processes autostart=true autorestart=true user=www-data stdout_logfile=/var/log/worker.log
Common Pitfalls
- Processing everything synchronously — Sending emails, generating PDFs, and calling external APIs during the HTTP request blocks the user. Queue everything that doesn't need an immediate response.
- Not handling worker failures — Queue workers can crash or be killed by OOM. Implement retry logic, dead letter queues, and idempotent job handlers to handle failures gracefully.
Best Practices
- Queue anything that takes more than 100ms — Email sending, image processing, report generation, and third-party API calls should all be processed asynchronously.
- Make queue jobs idempotent — Jobs should produce the same result if executed twice. This allows safe retry on failure without creating duplicate side effects.
Summary
- Message queues decouple slow operations from the HTTP request cycle, keeping response times fast.
- Use background workers for email, PDF generation, API calls, and any task taking more than 100ms.
- Design idempotent jobs with retry logic and dead letter queues for reliability.
Code Examples
php
<?php
declare(strict_types=1);
// Complete job queue system
interface JobHandler {
public function handle(array $payload): void;
}
class SendEmailHandler implements JobHandler {
public function handle(array $payload): void {
$email = $payload['email'];
$subject = $payload['subject'];
$body = $payload['body'];
// Send email...
mail($email, $subject, $body);
}
}
class JobQueue {
public function __construct(
private Redis $redis,
private string $prefix = 'queue:'
) {}
public function dispatch(string $queue, string $handler, array $payload, int $delay = 0): string {
$jobId = bin2hex(random_bytes(16));
$job = [
'id' => $jobId,
'handler' => $handler,
'payload' => $payload,
'attempts' => 0,
'created_at' => time(),
];
if ($delay > 0) {
// Delayed job
$this->redis->zAdd(
$this->prefix . 'delayed',
time() + $delay,
json_encode($job)
);
} else {
$this->redis->rPush($this->prefix . $queue, json_encode($job));
}
return $jobId;
}
public function migrateDelayed(): int {
$now = time();
$jobs = $this->redis->zRangeByScore(
$this->prefix . 'delayed',
'-inf',
(string) $now
);
foreach ($jobs as $jobJson) {
$job = json_decode($jobJson, true);
$this->redis->rPush($this->prefix . 'default', $jobJson);
$this->redis->zRem($this->prefix . 'delayed', $jobJson);
}
return count($jobs);
}
}
class JobWorker {
private bool $running = true;
public function __construct(
private Redis $redis,
private array $handlers,
private int $maxAttempts = 3
) {
pcntl_signal(SIGTERM, fn() => $this->running = false);
pcntl_signal(SIGINT, fn() => $this->running = false);
}
public function run(string $queue = 'default'): void {
$prefix = 'queue:';
while ($this->running) {
pcntl_signal_dispatch();
// Migrate delayed jobs
(new JobQueue($this->redis))->migrateDelayed();
// Fetch job
$result = $this->redis->blPop($prefix . $queue, 5);
if (!$result) continue;
$job = json_decode($result[1], true);
$this->processJob($job, $queue);
}
}
private function processJob(array $job, string $queue): void {
$job['attempts']++;
try {
$handler = $this->handlers[$job['handler']] ?? null;
if (!$handler) {
throw new RuntimeException("Unknown handler: {$job['handler']}");
}
$handler->handle($job['payload']);
echo "[OK] Job {$job['id']} processed\n";
} catch (Throwable $e) {
echo "[ERROR] Job {$job['id']}: {$e->getMessage()}\n";
if ($job['attempts'] < $this->maxAttempts) {
// Retry with exponential backoff
$delay = pow(2, $job['attempts']) * 60;
(new JobQueue($this->redis))->dispatch(
$queue,
$job['handler'],
$job['payload'],
$delay
);
} else {
// Move to failed queue
$job['error'] = $e->getMessage();
$job['failed_at'] = time();
$this->redis->rPush('queue:failed', json_encode($job));
}
}
}
}
// Usage
$redis = new Redis();
$redis->connect('127.0.0.1');
// Dispatch jobs
$queue = new JobQueue($redis);
$queue->dispatch('default', SendEmailHandler::class, [
'email' => 'user@example.com',
'subject' => 'Welcome!',
'body' => 'Thanks for signing up.',
]);
// Run worker
$worker = new JobWorker($redis, [
SendEmailHandler::class => new SendEmailHandler(),
]);
$worker->run();
?>