Events and Listeners
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. An event records that something happened, such as "a book was borrowed". Listeners react to it: update a counter, write an audit line, tell a librarian. The code that borrows the book does not know who is listening, so a new side effect is added without touching it. Bookshelf has two domain events, three reactions to them and one audit subscriber.
Events and listeners
php artisan make:event BookBorrowed and php artisan make:listener UpdateBorrowingStats --event=BookBorrowed
create the classes in app/Events and app/Listeners. An event is a plain object carrying data:
<?php
// app/Events/BookBorrowed.php
namespace App\Events;
use App\Models\Loan;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class BookBorrowed implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(public Loan $loan) {}
}
// app/Events/BookReturned.php: the same shape with (public Loan $loan, public bool $late), without ShouldDispatchAfterCommit
Source: Laravel — Defining Events
<?php
// app/Listeners/UpdateBorrowingStats.php
namespace App\Listeners;
use App\Events\BookBorrowed;
use Illuminate\Support\Facades\Cache;
class UpdateBorrowingStats // runs inside the request
{
public function handle(BookBorrowed $event): void
{
Cache::increment('stats:loans:'.now()->format('Y-m-d'));
}
}
// app/Listeners/NotifyLibrarianOfLoan.php
namespace App\Listeners;
use App\Events\BookBorrowed;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Log;
#[Tries(3)]
class NotifyLibrarianOfLoan implements ShouldQueue // runs in a worker
{
public string $queue = 'notifications';
public function handle(BookBorrowed $event): void
{
Log::info('librarian notified', ['loan' => $event->loan->id, 'book' => $event->loan->book->title]);
}
public function shouldQueue(BookBorrowed $event): bool
{
return $event->loan->book->published_year < 1900; // only rare books need a librarian
}
}
Dispatching is one line, BookBorrowed::dispatch($loan) (or event(new BookBorrowed($loan))).
Discovery and subscribers
Laravel finds listeners itself. It scans app/Listeners for handle and __invoke methods and reads the event
class from the parameter’s type hint. Nothing has to be registered. event:list shows the result:
$ php artisan event:list --event=App
App\Events\BookBorrowed
⇂ App\Listeners\UpdateBorrowingStats@handle
⇂ App\Listeners\NotifyLibrarianOfLoan@handle (ShouldQueue)
Source: Laravel — Event Discovery
A subscriber groups the handlers for several events in one class, which suits an audit log:
<?php
// app/Listeners/LoanAuditSubscriber.php
namespace App\Listeners;
use App\Events\{BookBorrowed, BookReturned};
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Log;
class LoanAuditSubscriber
{
public function onBorrowed(BookBorrowed $event): void
{
Log::info('audit: borrowed', ['loan' => $event->loan->id]);
}
public function onReturned(BookReturned $event): void
{
Log::info('audit: returned', ['loan' => $event->loan->id, 'late' => $event->late]);
}
public function subscribe(Dispatcher $events): array
{
return [BookBorrowed::class => 'onBorrowed', BookReturned::class => 'onReturned'];
}
}
// AppServiceProvider::boot()
Event::subscribe(LoanAuditSubscriber::class);
The subscriber sits in app/Listeners, but the event:list above, taken before the Event::subscribe line was
added, does not include it. Discovery only looks for handle and __invoke, so onBorrowed is not found. Once
registered:
$ php artisan event:list --event=App
App\Events\BookBorrowed
⇂ App\Listeners\LoanAuditSubscriber@onBorrowed
⇂ App\Listeners\UpdateBorrowingStats@handle
⇂ App\Listeners\NotifyLibrarianOfLoan@handle (ShouldQueue)
App\Events\BookReturned
⇂ App\Listeners\LoanAuditSubscriber@onReturned
Scanning the directory on every request costs time, so php artisan optimize (or event:cache) stores the map in
production. A listener added after the cache was built is ignored until the cache is rebuilt, which is one more
reason to run optimize on every deploy (Deployment).
Events and transactions
Listeners run when the event is dispatched. If that happens inside a transaction that later rolls back, they have already acted on data that no longer exists. Bookshelf created a loan in a transaction, dispatched both events and then threw:
<?php
try {
DB::transaction(function () use ($user, $kindred) {
$loan = Loan::create(['book_id' => $kindred->id, 'user_id' => $user->id, 'status' => 'active', 'due_at' => now()->plus(days: 21)]);
BookBorrowed::dispatch($loan); // ShouldDispatchAfterCommit
BookReturned::dispatch($loan, false); // dispatched at once
throw new RuntimeException('card declined');
});
} catch (RuntimeException) {
}
DB::transaction(function () use ($user, $kindred, $frankenstein) {
foreach ([$kindred, $frankenstein] as $book) {
BookBorrowed::dispatch(Loan::create(['book_id' => $book->id, 'user_id' => $user->id, 'status' => 'active', 'due_at' => now()->plus(days: 21)]));
}
});
1. after rollback, audit log: audit: returned
2. inside the transaction, audit log: none
after commit, audit log: audit: borrowed, audit: borrowed
queued listener jobs: 1 (only the 1818 book)
BookBorrowed was dropped with the rollback. BookReturned, without the interface, had already written an audit
line for a loan that was never saved. In the committed transaction, the events waited until the commit. Only
Frankenstein (1818) passed shouldQueue, so one listener job was queued. Kindred (1979) was handled without one.
The queued listener’s job carries the loan as a model identifier, like any job
(Queues and Jobs). In the first run of this page, the cleanup code deleted
the test loans before the worker started, and the job failed with ModelNotFoundException: No query results for model
[App\Models\Loan]. With the loan still there:
$ php artisan queue:work --queue=notifications --stop-when-empty
2026-10-05 22:14:26 App\Listeners\NotifyLibrarianOfLoan .. RUNNING
2026-10-05 22:14:26 App\Listeners\NotifyLibrarianOfLoan .. 13.49ms DONE
librarian notified {"loan":"01m4723taj59mz4jy6b6ftc5zh","book":"Frankenstein"}
Source: Laravel — Queued Event Listeners
A queued listener should either tolerate a missing model or carry the plain data it needs (an id and a title) instead of the model.
Model events and domain events
Eloquent fires its own events (creating, created, updating, saved, deleted and others), which observers
handle (BookObserver on Eloquent Models). They answer "a row changed",
and they do not fire for query-builder updates or mass deletes. A domain event such as BookBorrowed answers "this
business action happened". It is dispatched by the code that performs the action, carries what the action means, and
fires once even if the action touched several rows. Model events fit technical reactions such as cache keys, slugs and
search indexing. Domain events fit everything a product owner would recognise.
Event::defer() holds model events until a block has finished, so listeners see the whole result:
<?php
Event::listen('eloquent.created: '.Loan::class, function (Loan $loan) use (&$order) {
$order[] = 'created:'.$loan->book->title.':loans='.$loan->book->loans()->count();
});
Event::defer(function () use ($user, $frankenstein, &$order) {
Loan::create(['book_id' => $frankenstein->id, 'user_id' => $user->id, 'status' => 'active', 'due_at' => now()->plus(days: 21)]);
Loan::create(['book_id' => $frankenstein->id, 'user_id' => $user->id, 'status' => 'active', 'due_at' => now()->plus(days: 21)]);
$order[] = 'closure done';
});
Source: Laravel — Deferring Events
3. Event::defer order: closure done | created:Frankenstein:loans=3 | created:Frankenstein:loans=3
Source: Laravel — Deferring Events
Both created listeners ran after the closure and saw all three loans (one from the earlier transaction, two from
this block), instead of one and then two.
When not to use an event
An event hides control flow, because the code that dispatches it does not show what happens next. Use one when the reactions are independent and optional, so the borrowing still makes sense without them. When the action needs the result, call the service directly. A fee calculation that the loan cannot be saved without belongs in the borrowing code, not in a listener. A sync listener that throws aborts the request, so anything that can fail or is slow should be queued. Events that must reach other services belong on a message broker (Messaging Models and Patterns). Events that must reach browsers are broadcast (Broadcasting and Reverb).
Testing
Event::fake() stops listeners from running and records what was dispatched. Passing a list fakes only those
events. assertListening checks the wiring, and each listener is tested by calling handle():
<?php
// tests/Feature/EventTest.php
use App\Events\{BookBorrowed, BookReturned};
use App\Listeners\{NotifyLibrarianOfLoan, UpdateBorrowingStats};
use App\Models\{Book, Loan, User};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\{Cache, Event};
uses(RefreshDatabase::class);
test('the listeners are wired up', function () {
Event::fake();
Event::assertListening(BookBorrowed::class, UpdateBorrowingStats::class);
Event::assertListening(BookBorrowed::class, NotifyLibrarianOfLoan::class);
});
test('borrowing dispatches the event', function () {
Event::fake([BookBorrowed::class]); // other events still run
$loan = Loan::factory()->create();
BookBorrowed::dispatch($loan);
Event::assertDispatched(BookBorrowed::class, fn ($event) => $event->loan->is($loan));
Event::assertNotDispatched(BookReturned::class);
});
test('the stats listener counts loans', function () {
(new UpdateBorrowingStats)->handle(new BookBorrowed(Loan::factory()->create()));
expect(Cache::get('stats:loans:'.now()->format('Y-m-d')))->toBe(1);
});
test('only rare books are queued for the librarian', function () {
$listener = new NotifyLibrarianOfLoan;
$rare = Loan::factory()->for(Book::factory()->state(['published_year' => 1818]))->create();
$modern = Loan::factory()->for(Book::factory()->state(['published_year' => 1979]))->create();
expect($listener->shouldQueue(new BookBorrowed($rare)))->toBeTrue()
->and($listener->shouldQueue(new BookBorrowed($modern)))->toBeFalse();
});
Source: Laravel — Events Testing and Faking a Subset of Events
All four pass. Event::fake() without a list also stops model events, so observers that a factory relies on, such
as one that fills in a slug, do not run. Faking only the events under test avoids that.
Where older books and tutorials are outdated
-
The
$listenarray inEventServiceProvider(Stauffer ch. 16) is gone from new applications. Discovery has been on by default since Laravel 11, and the provider no longer exists. -
event:generatecreated classes from that array.make:eventandmake:listenerreplace it. -
Dispatching from model
boot()closures still works, but observers or domain events are easier to find and test. -
DB::afterCommit()around every dispatch is replaced byShouldDispatchAfterCommiton the event, orafterCommiton queued listeners.