Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Laravel Events and Listeners: Decoupling Without the Chaos

About Post

The controller method started innocently. Sign the contract, save it, return a response. Then someone needed an email to the tenant. Then a notification to the property manager. Then a PDF generated and stored. Then the first invoice. Then an audit log entry.

Now the "sign" method is sixty lines long, takes several seconds because it's sending emails inline, and if the PDF service hiccups, the tenant sees an error even though the contract was signed.

Events and listeners are Laravel's answer to this. Used well, they turn that pile into one line and a set of small, independent reactions. Used badly, they turn your app into a haunted house where things happen and nobody knows why. Let's do the first one.

The idea: announce, don't orchestrate

An event is a fact about something that already happened: ContractSigned, PaymentReceived, MaintenanceRequestClosed. A listener is one reaction to that fact.

The code that signs the contract doesn't need to know who cares. It announces the fact and moves on. Billing, email and auditing each subscribe on their own. Add a sixth reaction next month without touching the signing code at all.

Step 1: the event

namespace App\Events;

use App\Models\Contract;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ContractSigned implements ShouldDispatchAfterCommit
{
    use Dispatchable, SerializesModels;

    public function __construct(public Contract $contract) {}
}

Name events in the past tense. They describe what happened, not what should happen next. SendWelcomeEmail is a command pretending to be an event.

That ShouldDispatchAfterCommit interface is the most underrated line here. If you dispatch inside a database transaction and the transaction rolls back, the event is simply dropped. Without it, your listeners could email a tenant about a contract that, as far as the database is concerned, was never signed.

Step 2: the listeners

namespace App\Listeners;

use App\Events\ContractSigned;
use App\Mail\WelcomePack;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;

class SendWelcomePack implements ShouldQueue
{
    public function handle(ContractSigned $event): void
    {
        Mail::to($event->contract->tenant)->send(new WelcomePack($event->contract));
    }
}

Since Laravel 11, listeners in app/Listeners are discovered automatically from the type-hint on handle(). No manual registration. Run php artisan event:list to see what's wired to what.

ShouldQueue is where the real win is: the listener runs on a queue worker, not during the HTTP request. The user gets their response immediately, and if the mail server is slow or down, the job retries in the background instead of breaking the signing.

Step 3: dispatch

DB::transaction(function () use ($contract) {
    $contract->markAsSigned();

    ContractSigned::dispatch($contract);
});

The sixty-line method is now a few lines. Each reaction lives in its own small class that you can read, test and change on its own.

Events vs observers vs just calling the code

Laravel gives you three ways to "do something when something happens", and picking the wrong one is where the chaos starts.

UseWhen
A direct callThe step is part of the action and must succeed with it. Saving the signature itself is not a listener.
A domain eventSomething meaningful happened in the business, and other parts of the app react to it: emails, invoices, audit, notifications.
A model observerTechnical reactions to any save of a model, wherever it happens: generating a UUID, clearing a cache, keeping a search index in sync.

The trap with observers is that updated fires for every update, from every place, for every reason. "Email the tenant when the contract status changes" in an observer means a one-off script that loops over contracts to correct a data issue might email every tenant it touches. A domain event is dispatched on purpose, by the code that knows what actually happened.

The gotchas that bite in production

  • Mass updates skip model events. Contract::where('expires_at', '<', now())->update([...]) runs one SQL query. No models are loaded, so no observers fire. If your logic depends on them, loop over the models, or dispatch your own event after the update.
  • Listeners must be safe to run twice. Queued jobs can be retried. A listener that creates an invoice should check whether the invoice already exists, or rely on a unique constraint.
  • Order is not a contract. If listener B needs listener A's result, they aren't independent reactions. Make it one listener, or have A dispatch its own event.
  • Don't build event chains you can't follow. An event that triggers a listener that dispatches an event that triggers... is how "decoupled" becomes "impossible to debug". One or two hops is fine. Five is a mystery novel.

Testing with Event::fake

Test the two halves separately. First, that the action announces the event:

public function test_signing_dispatches_contract_signed(): void
{
    Event::fake([ContractSigned::class]);

    $contract = Contract::factory()->create();

    $this->actingAs(User::factory()->create())
        ->postJson("/api/contracts/{$contract->id}/sign")
        ->assertOk();

    Event::assertDispatched(ContractSigned::class, fn ($e) => $e->contract->is($contract));
}

Then test each listener directly: call handle() with an event you construct yourself, with Mail::fake() in place. You can also assert the wiring with Event::assertListening(ContractSigned::class, SendWelcomePack::class).

Notice the array passed to Event::fake(). Calling it with no arguments fakes every event, including Eloquent's own model events. If an observer sets a UUID on creating, your factories will suddenly produce models without one, and you'll lose half an hour wondering why. Fake only the events you're asserting on.

The rule of thumb: if the step must succeed for the action to count, call it directly. If it's a reaction to the action, make it a queued listener on a past-tense event, dispatched after commit.

Wrap-up

  • Events are facts, in the past tense. Listeners are independent reactions.
  • Queue listeners that talk to the outside world.
  • Use ShouldDispatchAfterCommit when dispatching inside transactions.
  • Domain events for business moments, observers for technical housekeeping.
  • Fake specific events in tests, never all of them by accident.

The Laravel events docs cover the rest, including event subscribers. Where do you draw the line between an observer and an event in your projects?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close