Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 10 – Events, queues, scheduling and real time

Events, listeners and model observers

React to a stored payment with listeners and observers, queue the slow reactions, keep them away from rolled-back data, and test events safely.

  • Intermediate
  • 15 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Handle an event with listeners that Laravel discovers, and queue the slow ones with ShouldQueue
  • Use an observer attached with #[ObservedBy], and name the writes that skip model events
  • Run a listener only after the database transaction commits
  • Decide between a call in the action, an event and a job for work that follows a save
  • Test code that dispatches events with Event::fake() without silencing its observers

Before you start

What you’ll learn · Before you start

What you will learn

  • Handle an event with listeners that Laravel discovers, and queue the slow ones with ShouldQueue
  • Use an observer attached with #[ObservedBy], and name the writes that skip model events
  • Run a listener only after the database transaction commits
  • Decide between a call in the action, an event and a job for work that follows a save
  • Test code that dispatches events with Event::fake() without silencing its observers

Before you start

On this page

When Pathshala’s front desk records a fee payment, three more things should follow: the day’s collection total moves, the guardian gets a receipt and the instalment’s change of status goes into an audit trail. If the payment code makes those three calls itself, a mail server that takes 2 seconds to answer keeps the cashier waiting 2 seconds for every payment, and each new reaction means editing the code that handles money.

Laravel’s events keep the payment apart from its reactions. The code that stores a payment dispatches one event, and each reaction is a listener the framework finds by itself. This lesson builds that for Pathshala: two listeners, one of them queued, an observer on the Instalment model, listeners that wait for the transaction to commit, and the tests. Every output below was recorded on Laravel 13.34.0.

One payment, many reactions: an event keeps them apart

The action that stores a payment is the place where money is handled, so it should hold only what decides whether the payment counts. Pathshala’s RecordPayment action saves the row inside a transaction, dispatches one event, refuses an overpayment and marks the instalment paid:

app/Actions/RecordPayment.php PHP · payments/app/Actions/RecordPayment.php
<?php

namespace App\Actions;

use App\Events\PaymentReceived;
use App\Models\Instalment;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;
use RuntimeException;

// Stores one payment against an instalment. What must succeed for the payment to count (the row, the overpayment
// check, the instalment's status) happens here, in one transaction; everything optional listens to the event.
class RecordPayment
{
    public function handle(Instalment $instalment, int $amountPaise, string $method = 'cash'): Payment
    {
        return DB::transaction(function () use ($instalment, $amountPaise, $method) {
            $payment = $instalment->payments()->create([
                'amount_paise' => $amountPaise,
                'method' => $method,
                'paid_at' => now(),
            ]);

            PaymentReceived::dispatch($payment);

            $paid = $instalment->payments()->sum('amount_paise');
            if ($paid > $instalment->amount_paise) {
                throw new RuntimeException("Instalment {$instalment->id} would be overpaid: {$paid} of {$instalment->amount_paise} paise");
            }

            if ($paid === $instalment->amount_paise) {
                $instalment->update(['status' => 'paid', 'paid_on' => now()]);
            }

            return $payment;
        });
    }
}

PaymentReceived is an application event: A small class that announces something that happened in the app, such as a stored payment, and carries its data. The code that dispatches it does not know who reacts.: a small class that carries the payment and names nobody who reacts to it. php artisan make:event PaymentReceived creates it, and php artisan make:listener SendPaymentReceipt --event=PaymentReceived creates an event listener: A class that reacts to one event class. Laravel registers it for the event that its handle or __invoke method is type-hinted with. whose handle() method is already type-hinted with the event.

No file lists which listener belongs to which event. With event discovery: Laravel's way of wiring listeners without a list: it scans app/Listeners and registers each handle or __invoke method for the event class in its signature. event:cache stores the result., Laravel scans app/Listeners and registers every handle or __invoke method for the event class in its signature. php artisan event:list prints what it found. The documentation advises caching that list during deployment with event:cache or optimize, and this run shows what the cache does to a listener added afterwards:

Two listeners discovered, a third hidden by the cache Python · discovery_session.py
from session import run

# Event discovery: event:list shows which listeners Laravel found for PaymentReceived by reading the type-hint of each
# handle() method in app/Listeners. Then the cached manifest of a deploy (event:cache) hides a listener added later,
# until the cache is built again.
run(
    """
php artisan event:list --event=PaymentReceived
php artisan event:cache
php artisan make:listener NotifyCentreManager --event=PaymentReceived
php artisan event:list --event=PaymentReceived
php artisan event:cache
php artisan event:list --event=PaymentReceived
""",
    app=True,
    overlay=("payments",),
)

Output

$ php artisan event:list --event=PaymentReceived
  App\Events\PaymentReceived .................................................
  ⇂ App\Listeners\UpdateCollectionTotals@handle
  ⇂ App\Listeners\SendPaymentReceipt@handle (ShouldQueue)
$ php artisan event:cache
   INFO  Events cached successfully.
$ php artisan make:listener NotifyCentreManager --event=PaymentReceived
   INFO  Listener [app/Listeners/NotifyCentreManager.php] created successfully.
$ php artisan event:list --event=PaymentReceived
  App\Events\PaymentReceived .................................................
  ⇂ App\Listeners\UpdateCollectionTotals@handle
  ⇂ App\Listeners\SendPaymentReceipt@handle (ShouldQueue)
$ php artisan event:cache
   INFO  Events cached successfully.
$ php artisan event:list --event=PaymentReceived
  App\Events\PaymentReceived .................................................
  ⇂ App\Listeners\UpdateCollectionTotals@handle
  ⇂ App\Listeners\SendPaymentReceipt@handle (ShouldQueue)
  ⇂ App\Listeners\NotifyCentreManager@handle

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 discovery_session.py

The first event:list shows both listeners, and marks the receipt listener (ShouldQueue). After event:cache, a new listener file exists but the next event:list still shows two: the framework reads the cached manifest instead of the folder. Only the second event:cache adds NotifyCentreManager.

A new listener on a server with cached events

Copying a new listener to a server and expecting it to run fails silently when the deploy cached the events: the old manifest has no entry for it, and no error says so. Rebuild the cache in the same deploy step that ships the code, with php artisan optimize or php artisan event:cache, and use php artisan event:clear while you develop.

A slow listener belongs on the queue

A receipt waits for a mail server, and a mail server can take seconds. A queued listener: An event listener that implements ShouldQueue. Dispatching the event stores a job, and a queue worker runs the listener later, outside the request. moves that wait out of the request: the class implements ShouldQueue, so dispatching the event stores a job, and a queue worker runs the listener later. The receipt listener needs one interface more than an ordinary listener:

app/Listeners/SendPaymentReceipt.php PHP · payments/app/Listeners/SendPaymentReceipt.php
<?php

namespace App\Listeners;

use App\Events\PaymentReceived;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;

// Sends the guardian a receipt. ShouldQueue: dispatching the event only stores a job; a queue worker runs this later,
// so the front desk does not wait for the mail server. (The mail itself is the mail-and-notifications lesson; here
// the receipt is written to the log.)
class SendPaymentReceipt implements ShouldQueue
{
    public function handle(PaymentReceived $event): void
    {
        $payment = $event->payment;
        $student = $payment->instalment->student;

        Log::info("Receipt for payment {$payment->id}: {$payment->amount_paise} paise from {$student->name}, sent to {$student->guardian_email}");
    }
}

The new project’s .env sets QUEUE_CONNECTION=database, and its first migrations already created the jobs table, so a queued listener works with no extra set-up. Step through one payment and watch which work is finished when the front desk’s command returns:

Interactive · step by step One payment and the two listeners it reaches Step through one payment from the front desk to the receipt, and watch which work happens before the command returns.
One payment and the two listeners it reaches, step 6 of 6. Showing RecordPayment, Event dispatcher, jobs table (0 jobs waiting), Totals listener (total is 50,000), Receipt listener (receipt logged), Queue worker. In focus: Receipt listener.RecordPaymentthe actionEvent dispatchertwo listeners foundjobs table0 jobs waitingTotals listenertotal is 50,000Receipt listenerreceipt loggedQueue workerits own processhandle()

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The action stores a payment. The front desk records 50,000 paise for Asha Verma. RecordPayment saves the row and dispatches PaymentReceived, an object that carries the payment and names no listener.
  2. Discovery found two listeners. At boot, event discovery read app/Listeners and found two handle() methods type-hinted with PaymentReceived. The dispatcher holds both, the same pair that event:list printed.
  3. The totals listener runs now. UpdateCollectionTotals does not implement ShouldQueue, so the dispatcher calls it at once, inside the request. The status line printed 50000 paise before the worker had started.
  4. The receipt becomes a job. SendPaymentReceipt implements ShouldQueue. The dispatcher does not call it; it stores one job that names the listener and the payment, and the request is finished.
  5. A worker takes the job. php artisan queue:work is a separate process. It takes the row, so the jobs table is empty again, and loads the payment from the database by its id.
  6. The receipt is written. The worker calls the listener's handle() method. In the recorded run the log gained one receipt line for payment 1, after the command at the front desk had already returned.

The same payment, recorded. The status lines come before and after the worker:

The total moves at once, the receipt waits for a worker Python · queued_receipt_session.py
from session import run

# One payment at the front desk: the synchronous listener has updated the total before the command returns, while the
# queued receipt listener is only a row in the jobs table until a worker runs it. The clock is frozen on a fixed day.
run(
    """
export PATHSHALA_NOW=2026-06-15T10:00:00
export CACHE_STORE=file
php artisan migrate --force
php artisan db:seed --class=PathshalaSeeder --force
php artisan payments:record PS-1001 50000
php artisan payments:status
php artisan jobs:waiting
php artisan queue:work --stop-when-empty
php artisan jobs:waiting
cat storage/logs/laravel.log
""",
    app=True,
    overlay=("payments",),
)

Output

$ export PATHSHALA_NOW=2026-06-15T10:00:00
$ export CACHE_STORE=file
$ php artisan migrate --force
   INFO  Running migrations.

  2026_01_05_090000_create_students_table ............................... DONE
  2026_01_05_091000_create_instalments_table ............................ DONE
  2026_01_05_092000_create_payments_table ............................... DONE
  2026_01_05_093000_create_instalment_changes_table ..................... DONE
$ php artisan db:seed --class=PathshalaSeeder --force
   INFO  Seeding database.

  Database\Seeders\PathshalaSeeder ................................... RUNNING
  Database\Seeders\PathshalaSeeder ...................................... DONE
$ php artisan payments:record PS-1001 50000
Payment 1 stored: 50000 paise for instalment 1 (paid)
$ php artisan payments:status
Payments stored: 1
Collected (cache total): 50000 paise
$ php artisan jobs:waiting
Jobs waiting in the queue: 1
$ php artisan queue:work --stop-when-empty
  … App\Listeners\SendPaymentReceipt ............... RUNNING
  … App\Listeners\SendPaymentReceipt .................. DONE
  … Worker STOPPED Queue empty
$ php artisan jobs:waiting
Jobs waiting in the queue: 0
$ cat storage/logs/laravel.log
[…] local.INFO: Receipt for payment 1: 50000 paise from Asha Verma, sent to guardian.ps1001@example.com

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 queued_receipt_session.py

The total said 50000 paise as soon as the payment was stored, because UpdateCollectionTotals ran inside the command. The receipt was one row in the jobs table until queue:work --stop-when-empty ran SendPaymentReceipt and stopped. The event uses SerializesModels, so the job holds the payment’s id and the worker loads the row again before it calls handle(); the log line names payment 1 and the guardian’s address.

Key idea

A listener that implements ShouldQueue costs the request one row in the jobs table; the slow work runs later, in a worker.

Workers, retries and failed jobs are the next lesson, Queues and jobs.

Checkpoint

Check: Events, listeners and discovery · Queued listeners and the worker

  1. Question 1 of 2 In the recorded run, event:cache ran, then make:listener created NotifyCentreManager. What did the next event:list --event=PaymentReceived print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The same two listeners as before

    event:cache writes a manifest of the listeners it found at that moment, and the framework reads that manifest instead of scanning app/Listeners. A listener added later stays invisible until event:cache or optimize runs again, or event:clear removes the manifest.

  2. Question 2 of 2 Right after payments:record PS-1001 50000, before any worker ran, what did the recorded status commands show?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A total of 50000 paise, and 1 job waiting

    A synchronous listener runs while the event is dispatched; a queued listener becomes a row in the jobs table. The recorded run printed 50000 paise and 1 job, then 0 jobs after queue:work --stop-when-empty.

Observers react to model events, not to every query

Eloquent announces fifteen moments in a model’s life. Twelve of them come in six pairs, one dispatched before the change is written and one after it: creating and created, updating and updated, saving and saved, deleting and deleted, restoring and restored, forceDeleting and forceDeleted. The other three are retrieved (an existing row was loaded), trashed (a soft delete finished) and replicating (a copy was made). These are model event: A moment in an Eloquent model's life, such as creating, updated or deleted, that the model announces when it is saved or deleted through a model instance. Query-builder writes announce none..

Pathshala’s audit trail needs one row each time an instalment’s status changes. A model observer: A class that groups an Eloquent model's event handlers in methods named after the events, such as updated, and is attached to the model with the ObservedBy attribute. groups a model’s handlers in methods named after the events, and the #[ObservedBy] attribute on the model attaches it:

app/Observers/InstalmentObserver.php PHP · payments/app/Observers/InstalmentObserver.php
<?php

namespace App\Observers;

use App\Models\Instalment;
use App\Models\InstalmentChange;

// Writes the audit trail: one row each time an instalment's status changes through the model.
class InstalmentObserver
{
    public function updated(Instalment $instalment): void
    {
        if ($instalment->wasChanged('status')) {
            InstalmentChange::create([
                'instalment_id' => $instalment->id,
                'from_status' => $instalment->getOriginal('status'),
                'to_status' => $instalment->status,
            ]);
        }
    }
}

The Instalment model carries #[ObservedBy([InstalmentObserver::class])] above its class. Two commands change statuses in two different ways: one loads instalment 1 and calls $instalment->update(), the other marks every late instalment overdue with a single query-builder update():

One audit row for three status changes Python · observer_session.py
from session import run

# The observer writes an audit row when an instalment's status changes through the model ($instalment->update()), and
# writes nothing for a query-builder update() that changes two rows in one statement. The clock is frozen on a fixed
# day, so "late" means due before that day.
run(
    """
export PATHSHALA_NOW=2026-06-15T10:00:00
php artisan migrate --force
php artisan db:seed --class=PathshalaSeeder --force
php artisan instalments:mark-paid 1
php artisan instalments:mark-overdue
php artisan instalments:audit
""",
    app=True,
    overlay=("payments",),
)

Output

$ export PATHSHALA_NOW=2026-06-15T10:00:00
$ php artisan migrate --force
   INFO  Running migrations.

  2026_01_05_090000_create_students_table ............................... DONE
  2026_01_05_091000_create_instalments_table ............................ DONE
  2026_01_05_092000_create_payments_table ............................... DONE
  2026_01_05_093000_create_instalment_changes_table ..................... DONE
$ php artisan db:seed --class=PathshalaSeeder --force
   INFO  Seeding database.

  Database\Seeders\PathshalaSeeder ................................... RUNNING
  Database\Seeders\PathshalaSeeder ...................................... DONE
$ php artisan instalments:mark-paid 1
Instalment 1 marked paid through the model
$ php artisan instalments:mark-overdue
2 instalments marked overdue with one query-builder update
$ php artisan instalments:audit
+------------+---------+------+
| Instalment | From    | To   |
+------------+---------+------+
| 1          | pending | paid |
+------------+---------+------+
Statuses now: paid, overdue, overdue

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 observer_session.py

Three instalments changed status, and the trail holds one row. The query-builder update() sent a single UPDATE statement for instalments 2 and 3 without loading them as models, so there was no model to announce anything. The documentation says the same of mass deletes. The writes compare like this:

The write Fires the observer’s updated or deleted
$instalment->update([...]), $instalment->save() Yes, when an attribute changed
$instalment->delete() Yes
Instalment::where(...)->update([...]) No: no model is loaded
Instalment::where(...)->delete() No: no model is loaded
$instalment->saveQuietly(), Instalment::withoutEvents(...) No: events are switched off on purpose

An observer that guards bulk changes

An audit or a cache refresh in an observer misses every row a bulk query changes, so a nightly command with one query-builder update() leaves no trace. When the trail must be complete, change such rows through the models (lazyById() keeps memory flat and stays correct when the loop changes the column it filters on), or write the trail rows in the same command, next to the bulk update.

Listeners that run before the commit count rolled-back payments

RecordPayment dispatches its event inside DB::transaction, before it checks for an overpayment. The documentation describes the transaction’s contract: an exception inside the closure rolls back everything written to the database and is thrown again. A database transaction: A group of database writes that succeed or fail together: either all of them are committed, or a rollback undoes every one. Work outside the database is not undone. undoes database writes only. Follow a second tap at the front desk for an instalment that is already paid:

Interactive · message by message A rolled-back payment and the total in the cache Step through the second tap at the front desk, and compare what the database and the cache hold at the end.
A rolled-back payment and the total in the cache, step 6 of 6. Messages so far: Action to Database: BEGIN; Action to Database: INSERT payment 2; Action to Cache total: +50,000 paise; Action to Database: ROLLBACK.ActionDatabase1 paymentCache total50,000BEGININSERT payment 2+50,000 paiseROLLBACK

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A transaction begins. Instalment 1 is already paid, and the front desk taps again. RecordPayment opens a transaction, so everything it writes to the database from now on is kept or undone together.
  2. The second payment goes in. The action inserts payment 2. Nothing is committed yet, so the row exists only inside this transaction.
  3. The listener moves the total. PaymentReceived is dispatched before the check. UpdateCollectionTotals runs at once and adds 50,000 paise to the total, which lives in a file cache, outside the transaction.
  4. The check throws. The action sums the instalment's payments, 100,000 paise against 50,000 due, and throws. DB::transaction catches the exception and rolls back.
  5. The rollback misses the cache. The database forgets payment 2. The cache never took part in the transaction, so the recorded total said 100,000 paise while one payment of 50,000 was stored.
  6. After commit, nothing to undo. With ShouldHandleEventsAfterCommit the dispatcher gives the listener to the open transaction and runs it only on commit. After the rollback it was dropped, and the recorded total stayed at 50,000.

Here is that tap, recorded with the cache in files so that it sits outside the database (the new project’s default database cache would join the same transaction and roll back with it):

One payment stored, 100,000 paise in the total Python · after_commit_session.py
from session import run

# A second tap at the front desk for an instalment that is already paid. The action stores the payment, dispatches
# PaymentReceived, then finds the overpayment and throws, so the transaction rolls back. The synchronous listener had
# already added 50,000 paise to the total in the cache, which the rollback cannot undo. The cache is the file store
# here (the skeleton's database store would sit inside the same transaction).
run(
    """
export PATHSHALA_NOW=2026-06-15T10:00:00
export CACHE_STORE=file
php artisan migrate --force
php artisan db:seed --class=PathshalaSeeder --force
php artisan payments:record PS-1001 50000
! php artisan payments:record PS-1001 50000
php artisan payments:status
""",
    app=True,
    overlay=("payments",),
)

Output

$ export PATHSHALA_NOW=2026-06-15T10:00:00
$ export CACHE_STORE=file
$ php artisan migrate --force
   INFO  Running migrations.

  2026_01_05_090000_create_students_table ............................... DONE
  2026_01_05_091000_create_instalments_table ............................ DONE
  2026_01_05_092000_create_payments_table ............................... DONE
  2026_01_05_093000_create_instalment_changes_table ..................... DONE
$ php artisan db:seed --class=PathshalaSeeder --force
   INFO  Seeding database.

  Database\Seeders\PathshalaSeeder ................................... RUNNING
  Database\Seeders\PathshalaSeeder ...................................... DONE
$ php artisan payments:record PS-1001 50000
Payment 1 stored: 50000 paise for instalment 1 (paid)
$ php artisan payments:record PS-1001 50000
Instalment 1 would be overpaid: 100000 of 50000 paise; the transaction was rolled back.
[exit status 1]
$ php artisan payments:status
Payments stored: 1
Collected (cache total): 100000 paise

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 after_commit_session.py

The rollback removed payment 2, and the total still counted it. The fix is to run the listener after commit: Running a handler only once the surrounding database transaction has committed, so it never acts on writes that a rollback undoes. With no transaction open, the handler runs at once.. Implementing ShouldHandleEventsAfterCommit, or adding a public $afterCommit = true property, makes the dispatcher hand the listener to the open transaction, which runs it on commit and drops it on rollback. With no transaction open, the listener runs at once.

after-commit-fix: app/Listeners/UpdateCollectionTotals.php PHP · after-commit-fix/app/Listeners/UpdateCollectionTotals.php
<?php

namespace App\Listeners;

use App\Events\PaymentReceived;
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;
use Illuminate\Support\Facades\Cache;

// The same listener, held back until the payment's transaction commits. If the transaction rolls back, the
// dispatcher drops it and the total never moves. Outside a transaction it runs at once, as before.
class UpdateCollectionTotals implements ShouldHandleEventsAfterCommit
{
    public function handle(PaymentReceived $event): void
    {
        Cache::increment('collected_paise', $event->payment->amount_paise);
    }
}
The same two taps, and the total stays at 50,000 Python · after_commit_fixed_session.py
from session import run

# The same two taps with the listener of after-commit-fix/: it implements ShouldHandleEventsAfterCommit, so the
# dispatcher holds it until the transaction commits and drops it when the transaction rolls back.
run(
    """
export PATHSHALA_NOW=2026-06-15T10:00:00
export CACHE_STORE=file
php artisan migrate --force
php artisan db:seed --class=PathshalaSeeder --force
php artisan payments:record PS-1001 50000
! php artisan payments:record PS-1001 50000
php artisan payments:status
""",
    app=True,
    overlay=("payments", "after-commit-fix"),
)

Output

$ export PATHSHALA_NOW=2026-06-15T10:00:00
$ export CACHE_STORE=file
$ php artisan migrate --force
   INFO  Running migrations.

  2026_01_05_090000_create_students_table ............................... DONE
  2026_01_05_091000_create_instalments_table ............................ DONE
  2026_01_05_092000_create_payments_table ............................... DONE
  2026_01_05_093000_create_instalment_changes_table ..................... DONE
$ php artisan db:seed --class=PathshalaSeeder --force
   INFO  Seeding database.

  Database\Seeders\PathshalaSeeder ................................... RUNNING
  Database\Seeders\PathshalaSeeder ...................................... DONE
$ php artisan payments:record PS-1001 50000
Payment 1 stored: 50000 paise for instalment 1 (paid)
$ php artisan payments:record PS-1001 50000
Instalment 1 would be overpaid: 100000 of 50000 paise; the transaction was rolled back.
[exit status 1]
$ php artisan payments:status
Payments stored: 1
Collected (cache total): 50000 paise

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 after_commit_fixed_session.py

The wait can sit in four places, depending on who must wait:

Where How What waits
The event ShouldDispatchAfterCommit on the event class Every listener of that event
One listener ShouldHandleEventsAfterCommit, or $afterCommit = true That listener
An observer ShouldHandleEventsAfterCommit on the observer Every method except creating, updating, saving, deleting, restoring and forceDeleting, which still run at once
A queued listener ShouldQueueAfterCommit, or after_commit set to true for the connection in config/queue.php The job’s push to the queue

The observer row comes from the dispatcher’s source: it leaves those six methods out of the wait, because they run before the write, and Model::save() stops when one of them returns false. A queued listener has the same problem in another form, which is why the queue connections have their own after_commit option, set to false in the new project.

Key idea

Anything a rollback cannot undo, such as a cache total, a message or a call to another service, waits for the commit.

Checkpoint

Check: Model events, observers and the writes that skip them · Listeners that wait for the commit

  1. Question 1 of 2 In the recorded observer run, instalments 2 and 3 changed to overdue, yet the audit table got no row for them. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The query-builder update changed them in one statement without loading them as models, so no model event fired

    Instalment::where(...)->update(...) sends one UPDATE statement to the database. No Instalment object exists, so nothing dispatches updated, and the observer that writes the trail is not called. The documentation says the same of mass deletes.

  2. Question 2 of 2 With the listener that implements ShouldHandleEventsAfterCommit, the front desk taps twice for the same instalment of 50,000 paise and the second tap rolls back. What total, in paise, did the cache hold at the end?

    Type a number.

    A hint

    The listener runs only when a transaction commits. How many of the two transactions committed?

    Show the answer: Question 2 of 2

    Answer: 50000 paise

    The first payment committed, so its listener ran and added 50,000 paise. The second transaction rolled back, so the dispatcher dropped the listener it had handed to it, and the total stayed at 50,000. Without the interface it reached 100,000.

Event, direct call or job: decide by what must succeed

The overpayment check sits in the action, not in a listener, and for a reason the first recorded run showed: a listener exists only when the framework registered it, and a stale event cache can leave a new one out without a word. Work that decides whether the payment counts belongs in the action, inside its transaction, where a failure rolls everything back together. Everything that only reacts to the payment can be a listener, and a listener that is slow or calls another service is queued.

A job is the third place. When the action needs one slow follow-up of its own, such as building a receipt PDF, the action can dispatch a job by name, and the queue lesson shows how. Answer the questions in the table for one piece of work that follows a payment:

Interactive · answer and compare Where should this piece of work go? Answer three questions about one piece of work that follows a payment, and the table rules out the places it should not go.
Must this work succeed for the payment to count?
Is the work slow, or does it call another service such as a mail or SMS gateway?
Will other parts of the app add their own reactions to the same moment later?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four places for the work that follows a payment, against what each one guarantees
Place Fails together with the save Keeps the request fast Action does not name it Safe when the save rolls back
A call in the action Runs: in the request, in order Good runs inside the transaction: if it throws, the payment rolls back with it Poor a slow gateway call adds its whole wait to the front desk Poor the action names every step, so each new reaction edits the action Mixed database writes roll back with the payment; a call to another service does not
A listener Runs: in the request, when dispatched Poor a stale event cache hid a new listener in the recorded run, so a rule here can skip Poor runs before the response, exactly like a direct call Good discovery wires a new listener file; the action stays the same Mixed the cache total reached 100,000 on a rollback until the listener waited for commit
A queued listener Runs: later, on a worker Poor it runs after the response, so it cannot stop the save Good the request stores one jobs row and returns Good discovered like any listener; the action only dispatches the event Mixed needs ShouldQueueAfterCommit or the after_commit option to wait for the commit
A job the action sends Runs: later, on a worker Poor it runs later, when the save has already been decided Good the request stores the job and returns Poor the action names the job it sends Mixed needs afterCommit to wait for the commit
How the answers decide
  • “Must this work succeed for the payment to count?” Yes, the payment is wrong without it: rules out A listener, A queued listener and A job the action sends. Work that decides whether the payment counts belongs in the action, inside its transaction, so a failure rolls both back and nothing can skip it.
  • “Must this work succeed for the payment to count?” No, it is a reaction to the payment and “Is the work slow, or does it call another service such as a mail or SMS gateway?” Yes, it is slow or calls out: rules out A call in the action and A listener. Slow or outside work after the response keeps the front desk fast, and a worker can try it again if it fails.
  • “Must this work succeed for the payment to count?” No, it is a reaction to the payment and “Will other parts of the app add their own reactions to the same moment later?” Yes, more reactions will come: rules out A call in the action and A job the action sends. When several reactions follow one moment, an event lets each live in its own listener without editing the action again.
  • “Must this work succeed for the payment to count?” No, it is a reaction to the payment and “Is the work slow, or does it call another service such as a mail or SMS gateway?” No, it is quick and local and “Will other parts of the app add their own reactions to the same moment later?” No, this is the only one: adds a caveat to A listener and A queued listener. One quick reaction can stay a direct call; an event adds a step to follow that pays off only when reactions multiply.

In short: rules go in the action, reactions go to listeners, slow reactions go to the queue, and every reaction that a rollback cannot undo waits for the commit. A listener that returns false also stops every listener after it, so a rule placed in a listener can be skipped by its neighbour as well.

Testing events without switching off your observers

A test of RecordPayment should prove that a payment dispatches PaymentReceived without sending receipts or moving totals. An event fake: A test double for the event dispatcher, installed by Event::fake(), that records dispatched events for assertions instead of running their listeners, model events included. does that: Event::fake() records every dispatched event instead of running its listeners, and Event::assertDispatched() checks what was recorded.

The fake has a reach that surprises people. Event::fake() also replaces the dispatcher that Eloquent uses, so model events are recorded instead of handled, and the observer goes quiet with everything else. The documentation warns about factories that rely on model events for the same reason. Pass the events to fake, Event::fake([PaymentReceived::class]), and everything else, model events included, runs as usual. Event::fakeExcept() turns it around: it fakes everything except the names you list (the documentation spells the same idea Event::fake()->except()), and a model event’s name has the form eloquent.updated: App\Models\Instalment.

tests/Feature/PaymentEventsTest.php PHP · payments/tests/Feature/PaymentEventsTest.php
<?php

namespace Tests\Feature;

use App\Actions\RecordPayment;
use App\Events\PaymentReceived;
use App\Listeners\SendPaymentReceipt;
use App\Models\Instalment;
use App\Models\InstalmentChange;
use App\Models\Student;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class PaymentEventsTest extends TestCase
{
    use RefreshDatabase;

    private function instalment(): Instalment
    {
        return Student::create(['name' => 'Asha Verma', 'roll_no' => 'PS-1001', 'guardian_email' => 'guardian.ps1001@example.com'])
            ->instalments()
            ->create(['due_on' => '2026-06-10', 'amount_paise' => 50000, 'status' => 'pending']);
    }

    public function test_a_payment_dispatches_payment_received_once(): void
    {
        Event::fake([PaymentReceived::class]);

        app(RecordPayment::class)->handle($this->instalment(), 50000);

        Event::assertDispatchedOnce(PaymentReceived::class);
        Event::assertDispatched(fn (PaymentReceived $event) => $event->payment->amount_paise === 50000);
        $this->assertNull(Cache::get('collected_paise'));
        $this->assertSame(1, InstalmentChange::count());
    }

    public function test_a_full_fake_silences_the_observer_too(): void
    {
        $instalment = $this->instalment();
        Event::fake();

        app(RecordPayment::class)->handle($instalment, 50000);

        $this->assertSame('paid', $instalment->fresh()->status);
        $this->assertSame(0, InstalmentChange::count());
    }

    public function test_fake_except_lets_the_observer_run(): void
    {
        $instalment = $this->instalment();
        Event::fakeExcept(['eloquent.updated: '.Instalment::class]);

        app(RecordPayment::class)->handle($instalment, 50000);

        Event::assertDispatched(PaymentReceived::class);
        $this->assertSame(1, InstalmentChange::count());
    }

    public function test_the_receipt_listener_is_wired_to_the_event(): void
    {
        Event::fake();

        Event::assertListening(PaymentReceived::class, SendPaymentReceipt::class);
    }
}
Four tests: a subset, a full fake, fakeExcept and the wiring Python · event_fake_session.py
from session import run

# COLLISION_IGNORE_DURATION=true: php artisan test prints no per-test times, so two runs print the same lines.

# Four tests of the payment action with Event::fake(): faking only PaymentReceived keeps the observer working, a full
# fake silences it too, fakeExcept() lets one model event through, and assertListening() checks the wiring.
run(
    """
export COLLISION_IGNORE_DURATION=true
php artisan test --filter=PaymentEventsTest
""",
    app=True,
    overlay=("payments",),
)

Output

$ export COLLISION_IGNORE_DURATION=true
$ php artisan test --filter=PaymentEventsTest
   PASS  Tests\Feature\PaymentEventsTest
  ✓ a payment dispatches payment received once
  ✓ a full fake silences the observer too
  ✓ fake except lets the observer run
  ✓ the receipt listener is wired to the event

  Tests:    4 passed (9 assertions)
  Duration: …s

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 event_fake_session.py

All four tests passed with 9 assertions. The first test faked only PaymentReceived and found one audit row, the second faked everything and found none, the third let the one model event through and found its row again, and the last used Event::assertListening() to check that the receipt listener is wired to the event.

Checkpoint

Check: A call in the action, a listener or a job · Testing events with Event::fake()

  1. Question 1 of 2 To keep the action short, a developer moves the overpayment check into a new listener of PaymentReceived. Using what the lesson recorded, what can go wrong?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: On a server whose event cache predates the listener, it is not registered, so overpayments are stored unchecked

    Whether a payment counts is a rule, and rules belong in the action, inside its transaction. A listener can be missing from a stale manifest, stopped by an earlier listener returning false, or queued later by someone who only sees "a slow reaction".

  2. Question 2 of 2 In the recorded tests, what held after a full Event::fake() and a recorded payment?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The instalment was paid, and the audit table had 0 rows

    Event::fake() swaps the dispatcher for a recorder, and that includes the one Eloquent dispatches model events through. Faking only PaymentReceived, or using Event::fakeExcept() with the model event's name, keeps the observer working.

Practise

Build the same pieces in your own Pathshala project:

  1. Create PaymentReceived and two listeners with make:event and make:listener --event, then run php artisan event:list --event=PaymentReceived. Run event:cache, add a third listener and run event:list again before and after a second event:cache.
  2. Make one listener queued with ShouldQueue, record a payment, count the rows of the jobs table, then run php artisan queue:work --stop-when-empty and read storage/logs/laravel.log.
  3. Attach an observer with #[ObservedBy], change one status with $model->update() and three with one query-builder update(), and count the audit rows.
  4. Set CACHE_STORE=file in .env, throw an exception after the dispatch inside DB::transaction, read the total, then implement ShouldHandleEventsAfterCommit on the listener and repeat.
  5. Write the four tests, and make the second one fail on purpose by expecting one audit row.

The self-check takes each idea in turn:

Practice that adapts to you

Practise this lesson

12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 Nothing in Pathshala lists which listeners belong to PaymentReceived. How does Laravel connect them?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: It scans app/Listeners and registers each handle or __invoke method for the event class in its signature

    With event discovery, the type-hint of handle(PaymentReceived $event) is the wiring. event:list shows what was found, and event:cache stores it for production.

  2. Question 2 of 12 A server ran php artisan optimize at its last deploy. A hotfix copies one new listener file into app/Listeners and changes nothing else. What happens when the event is next dispatched?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: The new listener does not run until the event cache is built again or cleared

    The documentation lists optimize and event:cache as the commands that cache the listener manifest. A deploy that ships listener code has to rebuild that cache in the same step.

  3. Question 3 of 12 SendPaymentReceipt implements ShouldQueue. What does dispatching PaymentReceived do with it?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: It stores a job for the listener, and a queue worker calls handle() later

    ShouldQueue turns the listener into a job on the queue connection of .env, which is the database queue in a new project. php artisan queue:work runs the job.

  4. Question 4 of 12 On a laptop, a developer adds ShouldQueue to the receipt listener. Payments still save, but no receipt line appears in the log any more. What is the most likely cause?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: No queue worker is running, so the receipt jobs wait in the jobs table

    Count the rows of the jobs table: one per payment means the jobs are there and nobody is taking them. Start php artisan queue:work, or queue:work --stop-when-empty to drain them once.

  5. Question 5 of 12 Which of these writes makes InstalmentObserver::updated() run?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: $instalment->update(['status' => 'paid'])

    Model events come from model instances. Writes that skip the model, or that switch its events off with saveQuietly() or withoutEvents(), reach no observer.

  6. Question 6 of 12 A nightly command marks 140 late instalments overdue with one Instalment::where(...)->update(...) call, then marks 6 more one by one with $instalment->update(['status' => 'overdue']). How many audit rows does InstalmentObserver write?

    Type a number.

    A hint

    Which of the two kinds of write dispatches a model event?

    Show the answer: Question 6 of 12

    Answer: 6 rows

    The single query-builder update changes 140 rows and dispatches nothing. Each of the 6 model updates changes the status of a loaded model, so updated runs 6 times and the observer writes 6 rows.

  7. Question 7 of 12 Inside DB::transaction, the action inserts a payment, dispatches PaymentReceived to a synchronous listener that adds the amount to a total in the file cache, then throws. What is still there after the rollback?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: The amount the listener added to the cache total

    This is the run the lesson recorded: one payment stored, 100,000 paise in the total. Work outside the database has to wait for the commit.

  8. Question 8 of 12 An observer implements ShouldHandleEventsAfterCommit. Its saving() method returns false for a negative amount, and its saved() method refreshes a search index. Inside a transaction, when does each run?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: saving() runs at once, and saved() waits for the commit

    In the 13.34 dispatcher, creating, updating, saving, deleting, restoring and forceDeleting are left out of the after-commit wait, and Model::save() stops when one of them returns false. The other methods wait.

  9. Question 9 of 12 Which piece of work after a payment belongs inside RecordPayment's transaction rather than in a listener?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Refusing a payment that would overpay the instalment

    Ask whether the payment is wrong without the work. If it is, the work is a rule and goes in the action; otherwise it is a reaction and can listen to the event.

  10. Question 10 of 12 After every payment, Pathshala must also post the amount to an outside accounting service that takes about 3 seconds. Nothing else reacts to it, and the payment counts whether or not the post succeeds. Which place fits?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Work on a worker after the commit, as a queued listener or a job the action sends

    The work does not decide whether the payment counts, and it is slow, so it belongs on the queue. With only one reaction, a queued listener and a job are equally good; either way it waits for the commit.

  11. Question 11 of 12 What does Event::fake() with no arguments do to InstalmentObserver in a test?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: It stops it, because the fake also replaces the dispatcher Eloquent uses for model events

    The recorded second test faked everything and found 0 audit rows. The documentation's advice to create factory models before calling Event::fake() comes from the same cause.

  12. Question 12 of 12 A test must check that recording a payment dispatches PaymentReceived, without running its listeners, while the audit observer still writes its row. Which set-up does that?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Event::fake([PaymentReceived::class])

    Event::fake() takes the events to fake; Event::fakeExcept() takes the events to let through. The first recorded test used the subset form and found one audit row.

Interview questions

Warm-up: an observer and a listener both react to events. How do they differ?

An event listener handles one application event, a class you dispatch yourself such as PaymentReceived, and Laravel wires it to that event by the type-hint of its handle() method. A model observer handles the events Eloquent dispatches about one model, such as created, updated and deleted, with one method per event, and you attach it to the model with the #[ObservedBy] attribute. A listener is often queued with ShouldQueue; an observer’s creating, updating, saving and deleting methods run before the write and can stop it by returning false. An observer is also blind to query-builder update() and delete() calls, which load no model and so fire no model event.

The module’s bank holds three longer questions about these ideas, each with a first step and follow-ups:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    PaymentReceived has three synchronous listeners, run in this order: add to the day's total in the cache, write a ledger row, tell the centre manager. About one payment in a hundred, the ledger listener throws. The event is dispatched inside the payment's transaction. What does the cashier see, what is left behind, and how would you restructure it?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Pathshala moves its queue from the database driver to Redis and changes nothing else. Within a day, some receipt jobs fail with ModelNotFoundException for payments that are in the database when you look a minute later. On the database driver this never happened. Explain why, and fix it.

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    A teacher publishes test marks for a class batch of 120 students. Pathshala must store the marks, recompute every student's rank in the batch, email each guardian their child's marks and refresh a cached leaderboard. Decide what goes in the action, in listeners and in jobs, and defend the design.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why does Pathshala's payment action dispatch one event instead of calling the receipt, totals and audit code itself?

    The action keeps only what decides whether the payment counts, and each reaction becomes a listener the framework finds by itself. A slow mail server then no longer holds the cashier, and a new reaction needs no edit to the code that handles money.

  2. Why can a new listener file fail to run on a server, with no error?

    Event discovery scans app/Listeners, but after event:cache (or optimize) the framework reads the cached manifest instead of the folder, so a listener added later is missing from it. Rebuild the cache in the deploy step that ships the code; use event:clear while you develop.

  3. What does implementing ShouldQueue change for a listener?

    Dispatching the event stores one row in the jobs table, and a queue worker runs the listener later, so the slow work leaves the request. The event uses SerializesModels, so the job holds the payment's id and the worker loads the row again.

  4. Which model events does Eloquent dispatch?

    Fifteen: six before-and-after pairs (creating/created, updating/updated, saving/saved, deleting/deleted, restoring/restored, forceDeleting/forceDeleted) plus retrieved, trashed and replicating. An observer groups a model's handlers, attached with the ObservedBy attribute.

  5. Why did three status changes leave only one row in an observer's audit trail?

    One instalment was loaded and updated as a model; the other two were changed by a single query-builder update(), which sends one UPDATE without loading models, so no model event fired. Mass deletes behave the same way.

  6. How do you stop a listener from counting a payment that the transaction later rolls back?

    Implement ShouldHandleEventsAfterCommit or set a public afterCommit property to true. The dispatcher then hands the listener to the open transaction, which runs it on commit and drops it on rollback; with no transaction open, it runs at once.

  7. Where does a rule such as refusing an overpayment belong: in a listener or in the action?

    In the action, inside its transaction. A listener exists only if the framework registered it, and a listener returning false stops the ones after it, so a rule there can be skipped. Rules go in the action, reactions to listeners, slow reactions to the queue.

  8. Why did Event::fake() silence a model observer in a test?

    It also replaces the dispatcher Eloquent uses, so model events are recorded instead of handled. Pass the events to fake, as in Event::fake([PaymentReceived::class]), or use Event::fakeExcept(), and everything else runs as usual.

Key takeaways

  • An event lets one action announce what happened while each reaction lives in its own listener, which discovery wires by the type-hint of handle(); a cached event manifest hides a listener added after event:cache until the cache is built again.
  • A listener that implements ShouldQueue costs the request one row in the jobs table, and a worker runs it later with the payment loaded again from the database.
  • Observers react to model events, which only writes made through a model dispatch, so a query-builder update() or delete() leaves an observer’s audit trail without a row.
  • A listener that runs before the commit can count a payment that was rolled back; ShouldHandleEventsAfterCommit, $afterCommit, ShouldDispatchAfterCommit and ShouldQueueAfterCommit make it wait.
  • Rules that decide whether a save counts belong in the action, reactions go to listeners and slow reactions to the queue.
  • Event::fake() silences observers too, so fake only the events a test is about, or use Event::fakeExcept().

Terms in this lesson

after commit
Running a handler only once the surrounding database transaction has committed, so it never acts on writes that a rollback undoes. With no transaction open, the handler runs at once.
application event
A small class that announces something that happened in the app, such as a stored payment, and carries its data. The code that dispatches it does not know who reacts.
database transaction
A group of database writes that succeed or fail together: either all of them are committed, or a rollback undoes every one. Work outside the database is not undone.
event discovery
Laravel's way of wiring listeners without a list: it scans app/Listeners and registers each handle or __invoke method for the event class in its signature. event:cache stores the result.
event fake
A test double for the event dispatcher, installed by Event::fake(), that records dispatched events for assertions instead of running their listeners, model events included.
event listener
A class that reacts to one event class. Laravel registers it for the event that its handle or __invoke method is type-hinted with.
model event
A moment in an Eloquent model's life, such as creating, updated or deleted, that the model announces when it is saved or deleted through a model instance. Query-builder writes announce none.
model observer
A class that groups an Eloquent model's event handlers in methods named after the events, such as updated, and is attached to the model with the ObservedBy attribute.
queued listener
An event listener that implements ShouldQueue. Dispatching the event stores a job, and a queue worker runs the listener later, outside the request.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.