DEV Community

Cover image for Laravel 13 Queue::route(): One Place to Control Your Entire Queue Topology
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Laravel 13 Queue::route(): One Place to Control Your Entire Queue Topology

Originally published at hafiz.dev


Every Laravel app I've worked on ends up with the same queue mess eventually. You start clean: one default queue, all jobs go there, life is simple. Then a client complains that emails are slow, so you spin up a dedicated emails queue with its own worker. Then Stripe webhooks start backing up, so billing gets its own queue too. Then AI processing jobs show up and they're eating into everything else, so heavy points at a beefier Redis connection with more memory.

Six months later, your queue topology lives in three different places at once: $queue properties on some job classes, ->onQueue() chains scattered across controllers and scheduled commands, and a handful of jobs that never got configured and quietly fall into the default queue. Change one queue name and you're grep-ing the entire codebase. Add a new developer and they have no idea where to look. The answer to "where does ProcessInvoice go?" is: everywhere, depending on who wrote the dispatch call.

Laravel 13 ships Queue::route(). Same philosophy as RateLimiter::for() for rate limits, or Route::model() for model binding. Infrastructure config belongs in one place, not spread across twenty job classes. This is the post I wished existed when I was cleaning up a SaaS codebase with eleven queues and zero consistency.

Here's how it works, how to use interface-based routing to make it scale, and how to migrate an existing app without breaking anything.

The Two Patterns You're Probably Mixing Right Now

Before Queue::route(), you had two real options when you needed a job on a specific queue.

Option one: class properties. Works, but it makes the job class aware of your infrastructure.

class ProcessInvoice implements ShouldQueue
{
    use Queueable;

    public string $queue = 'billing';
    public string $connection = 'sqs';
}
Enter fullscreen mode Exit fullscreen mode

ProcessInvoice shouldn't care that you're using SQS in production but database in staging. That's an environment concern, not a business logic concern. And if you want to move this job to a different queue, you have to touch the class itself. That should only happen when the business logic changes, not because you're reorganizing infrastructure.

Option two: chaining at dispatch. Pushes the infrastructure knowledge to the caller instead.

ProcessInvoice::dispatch($invoice)
    ->onQueue('billing')
    ->onConnection('sqs');
Enter fullscreen mode Exit fullscreen mode

Now every controller, command, and listener that dispatches this job has to remember to chain the right queue and connection. Miss one call site and the job silently lands on the default queue. No errors. No warnings. Just slower billing processing and a confused dev watching the billing Horizon worker idle while the default queue grows.

In practice, most apps end up mixing both patterns. Some jobs use class properties. Some use dispatch chaining. A few have it in both places and one overrides the other in a way nobody can remember without checking. When you onboard someone new, there's no obvious place to look. You just have to grep and hope.

Queue::route() replaces both patterns with one.

How Queue::route() Works

You register your queue topology once, in AppServiceProvider::boot():

use Illuminate\Support\Facades\Queue;

public function boot(): void
{
    Queue::route(ProcessInvoice::class, connection: 'sqs', queue: 'billing');
    Queue::route(SendWelcomeEmail::class, queue: 'emails');
    Queue::route(GenerateReport::class, connection: 'redis', queue: 'heavy');
    Queue::route(ProcessPodcast::class, queue: 'media');
}
Enter fullscreen mode Exit fullscreen mode

Done. Every dispatch of ProcessInvoice now automatically lands on the billing queue using the sqs connection, regardless of where in your codebase you dispatch it. No chaining. No class properties required.

// Goes to billing/sqs automatically
ProcessInvoice::dispatch($invoice);

// So does this, from anywhere in the app
dispatch(new ProcessInvoice($invoice));
Enter fullscreen mode Exit fullscreen mode

The job class itself stays clean:

class ProcessInvoice implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly Invoice $invoice,
    ) {}

    public function handle(): void
    {
        // just business logic here
    }
}
Enter fullscreen mode Exit fullscreen mode

No $queue. No $connection. No infrastructure noise at the top of a class that's supposed to be about billing logic.

Laravel also ships an array shorthand for batch registration:

Queue::route([
    ProcessInvoice::class   => ['billing', 'sqs'],  // queue + connection
    SendWelcomeEmail::class => 'emails',             // queue only, default connection
    GenerateReport::class   => ['heavy', 'redis'],
]);
Enter fullscreen mode Exit fullscreen mode

Both styles work. I prefer the per-line approach because it's easier to diff in code review, but the array syntax is useful when your service provider is getting long.

The Actually Clever Part: Interface and Trait Routing

Routing individual job classes is useful. But routing by interface is where the feature gets genuinely powerful, especially as your app grows.

Say you have a dozen jobs that all belong to your billing system: ProcessInvoice, RefundPayment, ChargeSubscription, GenerateReceipt, and so on. You could register each one individually. But you'd have to update AppServiceProvider every time a new billing job is added. That's the same maintenance overhead you were trying to escape.

Instead, create a marker interface:

namespace App\Contracts;

interface BillingJob {}
Enter fullscreen mode Exit fullscreen mode

Implement it on every billing job:

class ProcessInvoice implements ShouldQueue, BillingJob
{
    use Queueable;
    // ...
}

class RefundPayment implements ShouldQueue, BillingJob
{
    use Queueable;
    // ...
}

class ChargeSubscription implements ShouldQueue, BillingJob
{
    use Queueable;
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Then register once:

Queue::route(BillingJob::class, connection: 'sqs', queue: 'billing');
Enter fullscreen mode Exit fullscreen mode

Any job that implements BillingJob automatically routes to billing/SQS. Add a new billing job tomorrow, implement the interface, and it's routed correctly. No service provider changes needed.

The same pattern works with parent classes if you prefer inheritance over interfaces:

abstract class BillingJob implements ShouldQueue
{
    use Queueable;
}

class ProcessInvoice extends BillingJob {}
class RefundPayment extends BillingJob {}
Enter fullscreen mode Exit fullscreen mode

And with traits if you prefer composition:

trait IsBillingJob {}

Queue::route(IsBillingJob::class, connection: 'sqs', queue: 'billing');
Enter fullscreen mode Exit fullscreen mode

Pick whichever fits how you already organize your jobs. The routing resolution works the same way regardless of whether you pass a concrete class, an interface, a trait, or a parent class.

One thing worth understanding about precedence: a direct class registration always wins over an interface match. So if you route BillingJob to billing/sqs, but then add a specific route for ProcessInvoice pointing to billing-priority/sqs, the more specific rule wins for that one class while everything else continues using the interface route. Specific beats general. That's the behaviour you'd expect.

A Visual: How Dispatch Resolution Works

Here's the full picture of what happens after you've set up Queue::route():

flowchart LR
    A[ProcessInvoice::dispatch] --> R{Queue::route resolver}
    B[RefundPayment::dispatch] --> R
    C[SendWelcomeEmail::dispatch] --> S{Queue::route resolver}
    D[GenerateReport::dispatch] --> T{Queue::route resolver}
    R --> F[billing queue / SQS]
    S --> G[emails queue / default]
    T --> H[heavy queue / Redis]
Enter fullscreen mode Exit fullscreen mode

Every dispatch passes through the resolver. The resolver checks the job class against registered routes, then any interfaces, traits, and parent classes. First match wins. If nothing matches, the job falls through to the default queue for the connection, exactly as before.

Your dispatch call sites stay clean regardless of where in the app they live.

A Real Before and After

Here's what a typical app with three queues looks like before this change. Four dispatches, four different patterns:

// InvoiceController.php
ProcessInvoice::dispatch($invoice)
    ->onQueue('billing')
    ->onConnection('sqs');

// RefundController.php: someone forgot onConnection, wrong connection in production
RefundPayment::dispatch($refund)->onQueue('billing');

// SendWelcomeEmail.php: hardcoded property on the job class
class SendWelcomeEmail implements ShouldQueue
{
    public string $queue = 'emails';
}

// ReportCommand.php: no routing at all, silently falls to default queue
GenerateReport::dispatch($report);
Enter fullscreen mode Exit fullscreen mode

After the refactor, AppServiceProvider is the single source of truth:

public function boot(): void
{
    Queue::route(BillingJob::class, connection: 'sqs', queue: 'billing');
    Queue::route(SendWelcomeEmail::class, queue: 'emails');
    Queue::route(GenerateReport::class, queue: 'heavy');
}
Enter fullscreen mode Exit fullscreen mode

Every dispatch site becomes the same clean call:

ProcessInvoice::dispatch($invoice);
RefundPayment::dispatch($refund);
SendWelcomeEmail::dispatch($user);
GenerateReport::dispatch($report);
Enter fullscreen mode Exit fullscreen mode

The RefundPayment connection bug is gone. The silent GenerateReport routing issue is fixed. And anyone reading the codebase knows exactly where to look for queue config.

Migrating an Existing App

If you're upgrading to Laravel 13, this refactor pairs well with the upgrade itself. My Laravel 12 to 13 upgrade guide covers the upgrade process; this refactor can slot in right after or during.

Start by finding every $queue and $connection property across your job classes:

grep -rn 'public string \$queue\|public string \$connection' app/Jobs/
Enter fullscreen mode Exit fullscreen mode

Then find every dispatch call with explicit routing:

grep -rn 'onQueue\|onConnection' app/
Enter fullscreen mode Exit fullscreen mode

For each job you find, register a route in AppServiceProvider, remove the property from the class, and clean up the dispatch call. Run your test suite after each one. Don't try to do them all at once.

One important point: Queue::route() takes precedence over class properties if both exist. So you can register the route first, confirm it works in staging, and then clean up the property in a second commit. You're never in a state where the class property silently overrides your new central config during the transition.

One thing I'd also recommend doing during the migration: group your jobs by concern before writing the routes. Look for natural clusters: billing jobs, email jobs, media processing jobs, AI jobs. If you have a natural grouping, that's a sign you should probably use interface-based routing for the whole group rather than registering each class individually. Taking ten minutes to plan this before writing any code will save you a much longer conversation when the team wants to move a whole category of jobs to a new connection.

For a deep dive into how workers actually pick up named queues, set priorities, and handle Supervisor config, the post on processing 10,000 queue jobs without breaking covers all of that in detail. Worth reading alongside this refactor if your worker config is also a mess.

When ->onQueue() Still Makes Sense

Queue::route() sets the default for a job class. You can still override it at the dispatch site. The feature doesn't remove any flexibility. It just changes what happens when you don't specify.

This matters for priority overrides. Say you route most invoices to the billing queue, but premium customers need to jump ahead of the line:

// Standard dispatch: goes to billing via Queue::route()
ProcessInvoice::dispatch($invoice);

// Premium customer: override to fast-track queue
if ($customer->isPremium()) {
    ProcessInvoice::dispatch($invoice)->onQueue('billing-priority');
}
Enter fullscreen mode Exit fullscreen mode

The central default handles 99% of cases. Edge cases get explicit overrides at the dispatch site. Clean split between the rule and the exception.

Trade-offs Worth Being Honest About

It's only in Laravel 13+. If you're on Laravel 12, you can't use this. That's also a reasonable push to upgrade. Not because 12 is insecure (it gets security fixes until February 2027), but because the quality-of-life improvements add up. PHP Attributes for models, jobs, and commands. Cache::touch(). This. They're individually small. Together they make day-to-day work cleaner. The PHP Attributes post covers that side of the release if you want the full picture.

Discoverability moves to the service provider. A developer reading ProcessInvoice.php won't see which queue it uses without also knowing to check AppServiceProvider. If your team values job classes being fully self-documenting about their infrastructure setup, that's a real trade-off worth discussing. My take: infrastructure config shouldn't live on the class. The same argument came up when RateLimiter::for() shipped, and nobody complains about that pattern now. Centralization is the right call.

Test assertions still work, but for different reasons. If you have tests using Queue::assertPushedOn('billing', ProcessInvoice::class), those tests will still pass after the migration. But now they pass because of Queue::route() rather than an explicit ->onQueue() chain. That's actually more correct behavior. But it can be briefly confusing during migration if a previously-failing test suddenly passes. It's not a bug. It's the feature working as intended.

FAQ

Does Queue::route() work with Horizon?

Yes. Horizon reads queue names when picking up jobs. Queue::route() resolves before the job hits the queue, so Horizon sees the job on the correct named queue. Your Horizon config still controls worker counts and priorities per queue. Nothing about that changes.

What happens if I dispatch a job with no registered route and no class property?

It falls back to the default queue for the connection, exactly as before. Queue::route() is purely additive. Existing behavior doesn't break.

Does this work with batched and chained jobs?

For batched jobs, yes. Each job in a batch resolves its route independently through Queue::route(). For chained jobs, it depends on how the chain is configured. If you dispatch a chain with an explicit ->onQueue() or ->onConnection() at the chain level, those values take precedence over registered routes for all jobs in the chain, since chain-level queue settings apply to all jobs unless individually overridden. If no queue is specified at the chain level, Queue::route() resolves as normal for each job. Bottom line: test chain behavior in your specific setup before relying on it.

Can I use Queue::route() and still override at the dispatch site?

Yes. ->onQueue() and ->onConnection() at the dispatch site always override a registered route. The route is just the default when nothing is specified explicitly.

Does this work with mailables and notifications that implement ShouldQueue?

No. Queue::route() applies to job classes specifically. Mailables and notifications use their own onQueue() method and aren't affected by this feature.

What if a job matches multiple interface routes?

Direct class registrations win over interface registrations. Among interfaces, the first matching route wins. Design your interfaces so a job only ever matches one route. It keeps things predictable and avoids the kind of subtle ordering dependency that bites you six months later.

Conclusion

Queue::route() isn't flashy. It won't make it into any conference keynote highlight reel. But it's the kind of feature you notice every single day once you're using it: one file, one place to look, no surprises about where a job ends up.

Queue topology is infrastructure config. It belongs in AppServiceProvider next to rate limiters and model bindings. Not embedded in job classes and not repeated at every dispatch call site. If you're on Laravel 13 and have more than two queues, this refactor is worth an hour of your time this week. It's exactly the kind of thing that prevents the "wait, which queue does this job actually go to?" conversation at 2am when something's backing up.

The pattern also makes you think more clearly about your queue topology in general. When everything is in one file, you can see at a glance whether your infrastructure matches your mental model. Gaps become obvious. Misconfigurations stand out. It's a small change with a surprisingly large effect on how well the team understands the system.

Top comments (0)