Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Laravel 13 for queue-heavy apps: a reviewer's checklist for the new queue features

One production problem I traced wasn't a bug in one line. It was a request that waited on service A, which waited on B, which waited on a slow C, until everything timed out. The fix was to do only the required action syn

One production problem I traced wasn't a bug in one line. It was a request that waited on service A, which waited on B, which waited on a slow C, until everything timed out. The fix was to do only the required action synchronously and move the rest to queues.

So when a Laravel release changes queues, the question I care about is: does this make queue behaviour easier or harder to review?

Laravel 13 (released March 17, 2026, PHP 8.3+) adds a few features that move queue policy next to the code. This is a review of what the official 13.x docs say, plus the traps I'd check for in code review. It is not a report from a production migration.

1. Queue::route: one place for where jobs run

Before, the queue and connection for a job were often decided at each dispatch site (->onQueue('video')) or in properties on the job. Laravel 13 lets you set them centrally, usually in a service provider:

use Illuminate\Support\Facades\Queue;

public function boot(): void
{
    Queue::route(ProcessPodcast::class, connection: 'redis', queue: 'podcasts');

    // An interface, trait or parent class routes every job that uses it
    Queue::route(RequiresVideo::class, queue: 'video');
}

You can also route many classes at once with an array, and the docs say a job can still override its route.

The trap: argument order. The method signature is route($class, $queue = null, $connection = null): the queue comes before the connection. But the array form uses [connection, queue]:

Queue::route([
    ProcessPodcast::class => ['redis', 'podcasts'], // [connection, queue]
    ProcessVideo::class   => 'videos',              // queue only
]);

So Queue::route(ProcessPodcast::class, 'redis', 'podcasts') sends the job to a queue named redis on a connection named podcasts. In review, I'd ask for named arguments, as the docs use. (One caveat: Laravel's backward-compatibility promise doesn't cover parameter names.)

Routing is a default, not a lock-in. The docs say routing "can still be overridden by the job on a per-job basis", so a one-off dispatch can still go somewhere else:

ProcessPodcast::dispatch($podcast)
    ->onConnection('redis')
    ->onQueue('high');

The docs don't spell out the full precedence between a route and dispatch-time onQueue() / onConnection(). If a job's placement matters, I'd pin it with a test: Queue::fake() and Queue::assertPushedOn('high', ProcessPodcast::class).

Related: Queue::forward. The current 13.x docs also let you forward a whole queue to another queue and/or connection, so you can change queue infrastructure without touching jobs or dispatch sites:

Queue::forward('reports', 'reports.fifo', 'sqs');
Queue::forward('payments', connection: 'sqs');

route maps job classes; forward maps queue names. The docs add that an explicit connection configured on a job takes precedence over a forwarded connection.

2. Job attributes: retry and timeout rules at the top of the class

The 13.x docs configure these settings with attributes in Illuminate\Queue\Attributes (Tries, Backoff, Timeout and FailOnTimeout are new in 13):

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\FailOnTimeout;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;

#[Tries(25)]
#[MaxExceptions(3)]
#[Backoff([1, 5, 10])]
#[Timeout(120)]
#[FailOnTimeout]
class ProcessPodcast implements ShouldQueue
{
    use Queueable;

    public function handle(): void
    {
        // ...
    }
}

What each one means, from the docs:

  • #[Tries(25)]: up to 25 attempts. It takes precedence over the worker's --tries.
  • #[MaxExceptions(3)]: the job can be released many times (for example, when an API rate-limits it), but fails after 3 unhandled exceptions.
  • #[Backoff([1, 5, 10])]: wait 1 s, then 5 s, then 10 s for every later retry. With no backoff, a failed job is retried immediately.
  • #[Timeout(120)]: overrides the worker's --timeout (default 60 s).
  • #[FailOnTimeout]: by default a timeout uses one attempt and the job is retried. With this attribute, a timeout fails the job for good.

Attributes are optional; tries() / backoff() methods remain for dynamic logic. Stacking all five attributes on one class is my example; the docs show them one or two at a time.

What I'd check in review:

  • Timeout vs retry_after. The job timeout must be several seconds shorter than the connection's retry_after. If it isn't, a slow job can be picked up again while it's still running, and processed twice.
  • #[FailOnTimeout] is not idempotency. Picture a capture job: Laravel asks the gateway to capture 500 EGP, the gateway charges the card, and the connection drops before the response arrives. The job times out, and Laravel doesn't know whether the money moved. #[FailOnTimeout] stops the automatic retry after a timeout, but it doesn't guarantee the external operation didn't complete, and exceptions still retry up to #[Tries]. For payment jobs, send an idempotency key where the gateway supports one, and reconcile the payment status with the gateway before attempting another capture. (I covered the callback side of this in an earlier article.)
  • Timeouts on the HTTP client too. Blocking calls like outgoing HTTP may not respect the job timeout, so set connect and request timeouts on the client.
  • PCNTL. Job timeouts need the PCNTL extension on the worker.

3. A related cache feature: Cache::touch

This one isn't a queue feature, but queue-heavy apps often keep coordination data in the cache, and its expiry matters.

Cache::touch('key', 3600);
Cache::touch('key', now()->addHours(2));

It extends the lifetime of an existing item without reading and storing it again. Two things to watch:

  • It returns false when the key doesn't exist. It's not an upsert, so the caller has to handle the miss.
  • It's not for locks. A long job holding a cache lock should use Cache::lock(...)->refresh().

4. The upgrade notes a queue-heavy app should read

These are in the 13.x upgrade guide, and they're the kind of change that breaks a listener or empties a cache after deploy:

  • The JobAttempted event now has $exception instead of the boolean $exceptionOccurred.
  • The QueueBusy event's $connection is now $connectionName.
  • A new cache serializable_classes option is false in the new skeleton. If you cache PHP objects, you allow-list their classes.
  • Default cache and Redis key prefixes, and the session cookie name, now use hyphens when an app relies on the framework's fallback values. Most apps already define these in their config; if yours doesn't, set CACHE_PREFIX, REDIS_PREFIX and SESSION_COOKIE explicitly to keep the old behaviour, or you may start with a cold cache and logged-out users. Check this twice when several apps share the same Redis.

The checklist

  • [ ] Routes use named arguments (or the array form, read carefully), and placements that matter are pinned by a test
  • [ ] Every job's timeout is shorter than retry_after
  • [ ] Jobs that aren't safe to repeat use #[FailOnTimeout] and have a guard against running twice
  • [ ] Payment jobs use idempotency keys and reconcile status before another attempt
  • [ ] Jobs calling rate-limited APIs pair high #[Tries] with #[MaxExceptions] and a backoff
  • [ ] HTTP clients inside jobs have their own timeouts
  • [ ] Listeners on JobAttempted / QueueBusy are updated
  • [ ] CACHE_PREFIX, REDIS_PREFIX, SESSION_COOKIE and serializable_classes are set before deploying

Which queue setting has bitten you in production?

Sources: Laravel 13.x docs, Release notes, Queues, Cache, Upgrade guide, Queue facade API reference (accessed November 10, 2026).

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.