When a visitor places an order or submits a form, they expect an immediate response. If that action also triggers a confirmation email, a sync with management software or a PDF invoice, making them wait while all of that finishes degrades their experience, sometimes all the way to a timeout. Queues solve this problem, and bring another benefit: resilience to failures.

The principle

A queue lets you tell the application: “do this task, but not now: in the background, in a few seconds”. The user gets an immediate response, while a separate process, the worker, handles the actual task. In Laravel, these are jobs; in Symfony, the Messenger component.

In practice, with Laravel

The task is a class, pushed onto the queue when the order is placed:

class SendOrderConfirmation implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;                 // attempts before failing
    public array $backoff = [10, 60, 300]; // wait between attempts, in seconds

    public function __construct(public int $orderId) {}

    public function handle(): void
    {
        $order = Order::findOrFail($this->orderId);
        Mail::to($order->email)->send(new OrderConfirmation($order));
    }
}

// in the controller, after saving the order
SendOrderConfirmation::dispatch($order->id);

With Symfony, the principle is the same: a message ($bus->dispatch(new OrderPlaced($id))) and a handler that processes it. In both cases, you pass an ID rather than the full object: the task reloads up-to-date data when it runs.

Tasks that should almost always go through a queue

Resilience, and the rule that comes with it

If a carrier or an email service is temporarily down, the task stays in the queue and is retried automatically, instead of making the whole order fail for the customer. The payment itself stays immediate; it is the processing of its confirmation that benefits from a queue.

One rule then becomes essential: a retried task must never produce its effect twice (two invoices, two emails, two charges). It must be idempotent: before acting, it checks whether the work has already been done, for example with an “invoice generated” status stored in the database.

Running the worker

The worker is a process that runs continuously:

php artisan queue:work                    # Laravel
php bin/console messenger:consume async   # Symfony

In production, it must be restarted automatically if it stops (with Supervisor or systemd on a server), and restarted on every deployment of new code. On shared hosting that does not allow long-running processes, a scheduled task that starts the worker every minute until the queue is empty (queue:work --stop-when-empty) is an acceptable compromise for small volumes.

Monitor failures

A queue must be monitored. Tasks that have used up their attempts are kept aside and can be inspected and retried (php artisan queue:failed and queue:retry in Laravel, messenger:failed:show and messenger:failed:retry in Symfony). A dashboard such as Horizon, or a simple email alert when tasks fail, prevents a third-party outage from going unnoticed for days.

The checklist

  1. Emails, external calls, heavy files and webhooks go through a queue.
  2. Tasks receive IDs, not full objects.
  3. Number of attempts and delays between attempts defined.
  4. Idempotent tasks: retried, they do not produce duplicate effects.
  5. Worker supervised and restarted on every deployment.
  6. Failures kept, monitored and retryable.

Going further: my expertise: Freelance Laravel developer · read also: Laravel or Symfony: how to choose for your next PHP project