>Dudenkoff_
← Lab / Laravel · Queues

Queue the listener

Laravel 13 · PHP 8.5 · database driver · July 7, 2026 · part 5 of 9

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:

terminal
php artisan make:event OrderPlaced
php artisan make:listener SendOrderConfirmation --event=OrderPlaced
output
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:

app/Events/OrderPlaced.php
<?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:

app/Listeners/SendOrderConfirmation.php
<?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):

tinker
> $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:

app/Listeners/SendOrderConfirmation.php
<?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):

vendor · 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:

tinker
> $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
no interface - run it now
2004 ms
ShouldQueue - mail a letter
5.3 ms

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:

tinker
> 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:

tinker
> $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:

tinker
> 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:

terminal
php artisan queue:work --once
tail -n 1 storage/logs/laravel.log
output
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):

vendor · 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):

vendor · 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):

vendor · 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:

option A - queue the listener
class SendOrderConfirmation implements ShouldQueue { ... }

On the Mailable, only the sending does, and the listener may stay synchronous:

option B - queue the mail
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));
⚠ Don't put ShouldQueue on both. That's double queueing. The listener's job runs only to queue a second job for the email: two rows, two worker passes, one email. Pick a door: the listener when several actions hang off the event, the Mailable when sending is the only action.

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:

app/Listeners/SendOrderConfirmation.php
<?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()}");
    }
}
tinker
> 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.
  • ShouldQueue is the entire switch. The dispatcher asks reflection; without the interface the listener runs inside event() , in the user's request.
  • The listener rides by name, the event by value. CallQueuedListener carries the pair; the worker builds a fresh listener from the container every run.
  • The event's model is a claim tag. SerializesModels lives 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 ShouldQueue per 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.


Next
The limits of patience
The series
  1. 01 The minimal job
  2. 02 The model that never rode the queue
  3. 03 Beyond the minimal job
  4. 04 Where jobs go when they die
  5. 05 Queue the listener
  6. 06 The limits of patience
  7. 07 The door before the job
  8. 08 What a group costs
  9. 09 Workers in production