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
<?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)

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"}

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';
});
3. Event::defer order: closure done | created:Frankenstein:loans=3 | created:Frankenstein:loans=3

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();
});

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 $listen array in EventServiceProvider (Stauffer ch. 16) is gone from new applications. Discovery has been on by default since Laravel 11, and the provider no longer exists.

  • event:generate created classes from that array. make:event and make:listener replace 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 by ShouldDispatchAfterCommit on the event, or afterCommit on queued listeners.