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'sretry_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
falsewhen 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
JobAttemptedevent now has$exceptioninstead of the boolean$exceptionOccurred. - The
QueueBusyevent's$connectionis now$connectionName. - A new cache
serializable_classesoption isfalsein 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_PREFIXandSESSION_COOKIEexplicitly 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/QueueBusyare updated - [ ]
CACHE_PREFIX,REDIS_PREFIX,SESSION_COOKIEandserializable_classesare 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).
Top comments (0)