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.
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:
<?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:
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:
<?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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
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
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:
<?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():
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- The second payment goes in. The action inserts payment 2. Nothing is committed yet, so the row exists only inside this transaction.
- 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.
- 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.
- 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.
- 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):
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.
<?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);
}
} 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
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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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.
<?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);
}
} 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()
Practise
Build the same pieces in your own Pathshala project:
- Create
PaymentReceivedand two listeners withmake:eventandmake:listener --event, then runphp artisan event:list --event=PaymentReceived. Runevent:cache, add a third listener and runevent:listagain before and after a secondevent:cache. - Make one listener queued with
ShouldQueue, record a payment, count the rows of thejobstable, then runphp artisan queue:work --stop-when-emptyand readstorage/logs/laravel.log. - Attach an observer with
#[ObservedBy], change one status with$model->update()and three with one query-builderupdate(), and count the audit rows. - Set
CACHE_STORE=filein.env, throw an exception after the dispatch insideDB::transaction, read the total, then implementShouldHandleEventsAfterCommiton the listener and repeat. - 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.
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.
-
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?
-
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.
-
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.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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 afterevent:cacheuntil the cache is built again. - A listener that implements
ShouldQueuecosts 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()ordelete()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,ShouldDispatchAfterCommitandShouldQueueAfterCommitmake 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 useEvent::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
- Laravel 13.x events (Laravel (laravel.com))
- Laravel 13.x Eloquent, events and observers (Laravel (laravel.com))
- Laravel 13.x queues (Laravel (laravel.com))
- Laravel 13.x database, transactions (Laravel (laravel.com))
- Illuminate\Events\Dispatcher (13.34.0 source) (Laravel (GitHub))
- Illuminate\Database\Eloquent\Model (13.34.0 source) (Laravel (GitHub))
- Illuminate\Support\Facades\Event (13.34.0 source) (Laravel (GitHub))
Report a problem with this lesson
Kept only in this browser. Your Learn progress