Queue the listener
A user clicks “Place order” and stares at a spinner for two seconds while a confirmation email crawls out over SMTP. Four articles into this series, the cure is obvious: don't do slow work inside the request. But this time nobody writes a job. The codebase answers with an event and a listener - Laravel's other door to the queue. Today we walk through that door with a flashlight: flip the switch, open the payload, and find out who calls handle()
when the thing you dispatched isn't a job at all.
The cast#
The orders
table is still on the bench from the model article, so the only newcomers are the event and its listener:
php artisan make:event OrderPlaced
php artisan make:listener SendOrderConfirmation --event=OrderPlaced
INFO Event [app/Events/OrderPlaced.php] created successfully. INFO Listener [app/Listeners/SendOrderConfirmation.php] created successfully.
The event stub arrives dressed for broadcasting: InteractsWithSockets
, broadcastOn()
, a private channel. None of that is today's story; strip it down to what the event actually is: a fact with a model attached. Note what survives the stripping: SerializesModels
, which the generator put there on its own. Hold that thought:
<?php
namespace App\Events;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderPlaced
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}
The listener stub has a tell of its own: the generator already imported ShouldQueue
and InteractsWithQueue
without using them. Leave both lines where they are; they know something we're about to prove. Fill in handle()
with the same sleep(2)
stand-in for a slow SMTP send that The minimal job used, and deliberately do not queue anything yet:
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class SendOrderConfirmation
{
public function handle(OrderPlaced $event): void
{
sleep(2); // pretend we send an email over SMTP
logger()->info("confirmation sent for order #{$event->order->id}, total {$event->order->total}");
}
}
No wiring: the framework discovers the listener by the type-hint of its handle(OrderPlaced $event)
argument. The cast is complete.
The user waits two seconds#
In tinker: place an order, fire the event with Laravel's stopwatch on it (measure()
reports milliseconds):
> $o = App\Models\Order::create(['customer' => 'Henry', 'total' => 180, 'status' => 'new']);
= App\Models\Order {#8186
customer: "Henry",
total: 180,
status: "new",
updated_at: "2026-07-07 07:01:41",
created_at: "2026-07-07 07:01:41",
id: 8,
}
> Benchmark::measure(fn () => event(new App\Events\OrderPlaced($o)));
= 2003.930869
> DB::table('jobs')->count();
= 0
Two full seconds, and the queue is empty. The listener ran right inside event()
, in our process, before the next prompt appeared. Henry's confirmation is already in the log, and no worker exists anywhere. That's the default: an event is a synchronous function call wearing a costume.
The one-line fix#
Now redeem the stub's promise: the import has been waiting. One interface on the listener:
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class SendOrderConfirmation implements ShouldQueue
{
use InteractsWithQueue;
public function handle(OrderPlaced $event): void
{
sleep(2); // pretend we send an email over SMTP
logger()->info("confirmation sent for order #{$event->order->id}, total {$event->order->total}");
}
}
“Switch” is not a figure of speech here. The event dispatcher literally asks reflection whether the class carries the interface, and nothing else (Illuminate/Events/Dispatcher.php):
protected function handlerShouldBeQueued($class)
{
try {
return (new ReflectionClass($class))->implementsInterface(
ShouldQueue::class
);
} catch (Exception) {
return false;
}
}
The class changed, so restart tinker and run the same experiment:
> $o = App\Models\Order::create(['customer' => 'Iris', 'total' => 240, 'status' => 'new']);
= App\Models\Order {#8186
customer: "Iris",
total: 240,
status: "new",
updated_at: "2026-07-07 07:02:23",
created_at: "2026-07-07 07:02:23",
id: 9,
}
> Benchmark::measure(fn () => event(new App\Events\OrderPlaced($o)));
= 5.2726
> DB::table('jobs')->count();
= 1
Almost four hundred times faster, and this time the queue holds a row. The sleep(2)
didn't disappear; it moved out of event()
and into that row. Iris's confirmation is now a letter in the mailbox, so let's do what this series always does with letters.
Open the envelope#
Ask the payload the same questions we asked in The minimal job:
> json_decode(DB::table('jobs')->value('payload'), true)['displayName'];
= App\Listeners\SendOrderConfirmation {}
> json_decode(DB::table('jobs')->value('payload'), true)['job'];
= "Illuminate\\Queue\\CallQueuedHandler@call"
> json_decode(DB::table('jobs')->value('payload'), true)['data']['commandName'];
= Illuminate\Events\CallQueuedListener {}
> substr(json_decode(DB::table('jobs')->value('payload'), true)['data']['command'], 0, 68) . ' ...';
= "O:36:\"Illuminate\\Events\\CallQueuedListener\":28:{s:5:\"class\";s:35:\"Ap ..."
(Don't let the curly braces fool you: psysh decorates any string that happens to name a real class with a phantom {}
. Both are strings; a var_dump
below will settle it.) The envelope and the return address are the ones we know: CallQueuedHandler@call
, the clerk from the first article. The surprise is in commandName
. A job travels as itself; the listener doesn't get that privilege. Laravel wraps the pair “(listener, event)” into a service object (Illuminate\Events\CallQueuedListener
) and mails that. Crack it open - the three fields at the top are the story:
> $obj = unserialize(json_decode(DB::table('jobs')->value('payload'), true)['data']['command']);
= Illuminate\Events\CallQueuedListener {#7526
+class: "App\\Listeners\\SendOrderConfirmation",
+method: "handle",
+data: [
App\Events\OrderPlaced {#7524
+order: App\Models\Order {#8204
id: 9,
customer: "Iris",
total: 240,
status: "new",
created_at: "2026-07-07 07:02:23",
updated_at: "2026-07-07 07:02:23",
deleted_at: null,
},
},
],
+tries: null,
+maxExceptions: null,
+backoff: null,
+retryUntil: null,
+timeout: null,
+failOnTimeout: false,
+shouldBeEncrypted: false,
+deleteWhenMissingModels: false,
+shouldBeUnique: false,
+shouldBeUniqueUntilProcessing: false,
+uniqueId: null,
+uniqueFor: null,
+job: null,
+connection: null,
+queue: null,
+messageGroup: null,
+deduplicator: null,
+debounceOwner: "",
+delay: null,
+afterCommit: null,
+middleware: [],
+chained: [],
+chainConnection: null,
+chainQueue: null,
+chainCatchCallbacks: null,
}
> var_dump($obj->class);
string(35) "App\Listeners\SendOrderConfirmation"
= null
> $obj->method;
= "handle"
> get_class($obj->data[0]);
= App\Events\OrderPlaced {}
Read the top three fields. class
is a string(35)
- the listener rides by name, and the worker will build a fresh one from the container (the = null
is var_dump
's own return value; tinker prints every one). data[0]
is the event, riding by value, serialized whole. Which is why SerializesModels
sits on the event, not the listener: the event is the data. And below them, an old acquaintance: tries
, backoff
, timeout
… - the panel of dormant switches every job carries, all in the off position.
The tag rides again#
The dump shows a full Order
inside the event: customer, total, timestamps. Before believing it, search the serialized command itself:
> str_contains(json_decode(DB::table('jobs')->value('payload'), true)['data']['command'], 'ModelIdentifier');
= true
> str_contains(json_decode(DB::table('jobs')->value('payload'), true)['data']['command'], '"total"');
= false
No total
in the command; a ModelIdentifier
instead. Iris rides the queue as a claim tag, and the full order in our dump was fetched from the database the moment unserialize
ran, right under our fingers. Everything the model article proved (freshness, snapshots, vanished orders, trashed()
) applies to events verbatim, because it was never about jobs. It's about SerializesModels
, and the trait doesn't care what class it's standing on.
Who calls handle()#
The letter is still in the box. Run the worker and watch the timestamps:
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
2026-07-07 07:03:38 App\Listeners\SendOrderConfirmation .. RUNNING 2026-07-07 07:03:40 App\Listeners\SendOrderConfirmation .. 2s DONE [2026-07-07 07:03:40] local.INFO: confirmation sent for order #9, total 240
The 2s
between RUNNING
and DONE
is our sleep(2)
, paid in the worker's process instead of the user's request. Now the last dark stretch: how does a row in a table become a call to our method? The payload already gave us the route. Stop one - the return address. The worker parses CallQueuedHandler@call
and calls it (Illuminate/Queue/Jobs/Job.php):
public function fire()
{
$payload = $this->payload();
[$class, $method] = JobName::parse($payload['job']);
($this->instance = $this->resolve($class))->{$method}($this, $payload['data']);
}
Stop two, the clerk unseals the envelope. Look at what the try
guards (Illuminate/Queue/CallQueuedHandler.php):
public function call(Job $job, array $data)
{
try {
$command = $this->setJobInstanceIfNecessary(
$job, $this->getCommand($data) // <- unserialize($data['command'])
);
} catch (ModelNotFoundException $e) {
return $this->handleModelNotFound($job, $e);
}
// ... uniqueness, debounce, middleware ...
}
That getCommand
is the same unserialize
we ran by hand. And since unserializing redeems the claim tag with a database query, the clerk stands ready for ModelNotFoundException
: Iris's order could have vanished while the letter waited. Stop three, the wrapper finally spends its two fields (Illuminate/Events/CallQueuedListener.php):
public function handle(Container $container)
{
$this->prepareData();
$handler = $this->setJobInstanceIfNecessary(
$this->job, $container->make($this->class) // a brand-new listener, fresh DI
);
$handler->{$this->method}(...array_values($this->data));
}
$container->make($this->class)
- the name becomes a fresh listener; $this->data
- the event, tag already redeemed, lands in its handle()
. The route was fire()
→ call()
→ handle()
, and I didn't know it in advance. Two greps over vendor
walked it for me: first for the CallQueuedHandler@call
string from the payload, then for function fire
. The payload tells you where to look.
Two doors, one queue#
A confirmation email offers ShouldQueue
two homes, and the choice is real. On the listener, everything it does goes to the background:
class SendOrderConfirmation implements ShouldQueue { ... }
On the Mailable, only the sending does, and the listener may stay synchronous:
class OrderConfirmationMail extends Mailable implements ShouldQueue { ... }
// the listener stays synchronous; send() sees ShouldQueue and queues the mailable:
Mail::to('customer@example.com')->send(new OrderConfirmationMail($order));
A job like any other#
Everything the series has proved about jobs carries over, because the payload we opened is a job. Dispatched inside a transaction, the event inherits every transactional surprise from Beyond, and the cure has event-world spellings: ShouldQueueAfterCommit
on the listener, ShouldDispatchAfterCommit
on the event, or the per-connection 'after_commit' => true
.
When handle()
throws, the story is the morgue: attempts, backoff, failed_jobs
, queue:retry
. The patience switches are the same two attributes the morgue flipped - #[Tries]
and #[Backoff]
- and they ride on a listener exactly as they ride on a job. The stub's second import earns its keep here: InteractsWithQueue
hands the running listener its own job. The full file, patience edition, receipt included:
<?php
namespace App\Listeners;
use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\InteractsWithQueue;
#[Tries(3)]
#[Backoff(10)]
class SendOrderConfirmation implements ShouldQueue
{
use InteractsWithQueue;
public function handle(OrderPlaced $event): void
{
sleep(2); // pretend we send an email over SMTP
logger()->info("confirmation sent for order #{$event->order->id}, total {$event->order->total}");
}
public function failed(OrderPlaced $event, \Throwable $e): void
{
logger()->error("confirmation failed for order #{$event->order->id}: {$e->getMessage()}");
}
}
> event(new App\Events\OrderPlaced(App\Models\Order::find(9))); json_decode(DB::table('jobs')->value('payload'), true)['maxTries'];
= 3
> json_decode(DB::table('jobs')->value('payload'), true)['backoff'];
= "10"
> DB::table('jobs')->delete();
= 1
When something in a framework feels like magic, that's not wonder - that's a hole in your map. Don't take anyone's word for it, including mine: SELECT
the job, unserialize
the envelope, grep the source until it confesses. There is no magic. There is a row in a table, and you can read it.
The intermission in one card. Six lines worth keeping:
- A queued listener is a job the framework wrote for you. Same envelope, same clerk, same table.
ShouldQueueis the entire switch. The dispatcher asks reflection; without the interface the listener runs insideevent(), in the user's request.- The listener rides by name, the event by value.
CallQueuedListenercarries the pair; the worker builds a fresh listener from the container every run. - The event's model is a claim tag.
SerializesModelslives on the event - the model article's rules apply unchanged. - The route is
fire()→call()→handle(). The payload names every stop; two greps over vendor walk it. - One
ShouldQueueper email. Listener or Mailable - on both at once, the first job's only work is queueing the second.
Every letter today was fetched by a hand-run worker: queue:work --once
, each time. Who keeps workers alive around the clock, and what happens when two of them grab at the same row, is a story the series still owes. A later visit collects it.
- 01 The minimal job
- 02 The model that never rode the queue
- 03 Beyond the minimal job
- 04 Where jobs go when they die
- 05 Queue the listener
- 06 The limits of patience
- 07 The door before the job
- 08 What a group costs
- 09 Workers in production