PHP cookbook · Intermediate

Send emails asynchronously with a queue in PHP

Send welcome and receipt emails without slowing down requests or losing them when the mail provider is down.

Approach

Don't send email inside the web request. Record the intent in the database (an outbox table) in the same transaction as the business change, and let a worker deliver it with retries and backoff. Use a library like Symfony Mailer for transport. Frameworks provide queues (Symfony Messenger, Laravel Queues) that implement this for you.

Solution

<?php
declare(strict_types=1);
// Inside the request: same transaction as the order
$pdo->beginTransaction();
$orderId = $orders->create($cart);
$pdo->prepare('INSERT INTO outbox (type, payload, available_at) VALUES (?, ?, NOW())')
    ->execute(['order_receipt', json_encode(['order_id' => $orderId])]);
$pdo->commit();

// worker.php (run under systemd/supervisor)
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;

$mailer = new Mailer(Transport::fromDsn(getenv('MAILER_DSN')));
while (true) {
    $pdo->beginTransaction();
    $job = $pdo->query("SELECT * FROM outbox WHERE sent_at IS NULL AND available_at <= NOW()
                        ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED")->fetch(PDO::FETCH_ASSOC);
    if (!$job) { $pdo->commit(); sleep(1); continue; }
    try {
        $data = json_decode($job['payload'], true, flags: JSON_THROW_ON_ERROR);
        $mailer->send(buildReceiptEmail($data['order_id']));
        $pdo->prepare('UPDATE outbox SET sent_at = NOW() WHERE id = ?')->execute([$job['id']]);
    } catch (Throwable $e) {
        $delay = min(3600, 2 ** (int) $job['attempts'] * 30);
        $pdo->prepare('UPDATE outbox SET attempts = attempts + 1, last_error = ?, available_at = NOW() + make_interval(secs => ?) WHERE id = ?')
            ->execute([substr($e->getMessage(), 0, 500), $delay, $job['id']]);
    }
    $pdo->commit();
}

Pitfalls

  • Sending emails before the transaction commits (email for a rolled-back order).
  • No max attempts / dead-letter handling.
  • Workers without a supervisor, so they silently stop.

Variations

  • Symfony Messenger with the Doctrine or Redis transport and retry_strategy.
  • Laravel: Mail::to($user)->queue(new OrderReceipt($order)) with php artisan queue:work.

Further reading: Symfony Messenger

Reference: https://symfony.com/doc/current/messenger.html

More PHP recipes