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))withphp artisan queue:work.
Further reading: Symfony Messenger
Reference: https://symfony.com/doc/current/messenger.html
More PHP recipes
- Add CSRF protection to PHP forms
- Add health check endpoints to a PHP app
- Add structured logging with request IDs in PHP
- Build a secure password reset flow in PHP
- Cache expensive queries with Redis in PHP
- Call external APIs with timeouts and retries in PHP
- Handle file uploads securely in PHP
- Implement rate limiting in a PHP API