Backend

Laravel Events and Listeners

Mayur Dabhi
Mayur Dabhi
April 11, 2026
14 min read

Your registration controller starts out with three lines: validate, create the user, redirect. Six months later it also sends a welcome email, creates a profile row, pings Slack, calls an analytics API and sets up a trial. Every new requirement means editing the same method, and one slow mail server makes signup hang for everyone.

Laravel's events and listeners fix that by letting the controller announce "a user registered" and leaving the follow-up work to separate classes. This post covers creating and dispatching events, sync vs queued listeners, subscribers, model observers, broadcasting, testing, and a couple of gotchas that bite people in production.

Why Events and Listeners?

Take that signup flow: welcome email, default profile, admin notification, analytics, free trial. Without events it all sits in one controller method. With events, each concern lives in its own listener, so you can test it alone, switch it off, or add a new one without touching the others.

How Laravel's Event System Works

Think of it as a small publish/subscribe bus inside your app. Your code dispatches (publishes) an event, and zero or more listeners (subscribers) react to it. The mapping between events and listeners lives in the EventServiceProvider (or gets discovered automatically, more on that later).

Laravel resolves listener classes through the service container, so a listener can type-hint any dependency in its constructor and get it injected, the same way controllers and jobs do.

Controller dispatches event event() UserRegistered Event class Laravel Event Bus SendWelcomeEmail CreateUserProfile NotifyAdminSlack Laravel Event Dispatch Flow One event dispatched, multiple independent listeners react

A single dispatched event fans out to all registered listeners

Creating Events and Listeners

Artisan generates both classes for you. The convention is to name events in the past tense (something that happened) and listeners as actions (something that should happen next).

1

Generate the Event class

Run php artisan make:event UserRegistered to create app/Events/UserRegistered.php.

2

Generate the Listener class

Run php artisan make:listener SendWelcomeEmail --event=UserRegistered to create app/Listeners/SendWelcomeEmail.php pre-typed to your event.

3

Register in EventServiceProvider

Map the event to its listeners inside the $listen array of app/Providers/EventServiceProvider.php. On Laravel 11+, listeners in app/Listeners are discovered automatically, so you can often skip this step.

4

Dispatch the event

Call event(new UserRegistered($user)) or UserRegistered::dispatch($user) from anywhere in your application.

The Event Class

app/Events/UserRegistered.php
<?php

namespace App\Events;

use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class UserRegistered
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * The newly registered user.
     */
    public User $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }
}
SerializesModels Trait

When an event is queued, SerializesModels stores Eloquent models by their primary key instead of the full object. When the job runs, Laravel re-fetches the model from the database, so the listener sees current data rather than a stale snapshot.

The Listener Class

app/Listeners/SendWelcomeEmail.php
<?php

namespace App\Listeners;

use App\Events\UserRegistered;
use App\Mail\WelcomeEmail;
use Illuminate\Support\Facades\Mail;

class SendWelcomeEmail
{
    /**
     * Handle the event.
     */
    public function handle(UserRegistered $event): void
    {
        Mail::to($event->user->email)
            ->send(new WelcomeEmail($event->user));
    }
}

Registering Events in EventServiceProvider

app/Providers/EventServiceProvider.php
<?php

namespace App\Providers;

use App\Events\UserRegistered;
use App\Events\OrderPlaced;
use App\Listeners\SendWelcomeEmail;
use App\Listeners\CreateUserProfile;
use App\Listeners\NotifyAdminSlack;
use App\Listeners\SendOrderConfirmation;
use App\Listeners\UpdateInventory;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        UserRegistered::class => [
            SendWelcomeEmail::class,
            CreateUserProfile::class,
            NotifyAdminSlack::class,
        ],

        OrderPlaced::class => [
            SendOrderConfirmation::class,
            UpdateInventory::class,
        ],
    ];
}
Listener Running Twice?

A common one after upgrading to Laravel 11: you register a listener manually (in $listen or with Event::listen()) and event discovery also finds it in app/Listeners. Now it fires twice and users get two welcome emails. Run php artisan event:list to see exactly what's wired to each event, and pick one registration style.

Queued Listeners for Heavy Work

Sending emails, calling external APIs and generating reports are all slow. A user shouldn't sit on a spinner while your registration controller waits for SMTP. Queued listeners push that work onto Laravel's queue so the request can return right away.

To queue a listener, implement the ShouldQueue interface. You don't need to write a separate job class; Laravel wraps the listener in one for you.

app/Listeners/SendWelcomeEmail.php (queued version)
<?php

namespace App\Listeners;

use App\Events\UserRegistered;
use App\Mail\WelcomeEmail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;

class SendWelcomeEmail implements ShouldQueue
{
    use InteractsWithQueue;

    /**
     * The queue this listener should run on.
     */
    public string $queue = 'emails';

    /**
     * Delay in seconds before processing.
     */
    public int $delay = 30;

    /**
     * Number of times to retry on failure.
     */
    public int $tries = 3;

    public function handle(UserRegistered $event): void
    {
        Mail::to($event->user->email)
            ->send(new WelcomeEmail($event->user));
    }

    /**
     * Handle a job failure.
     */
    public function failed(UserRegistered $event, \Throwable $exception): void
    {
        // Log failure, notify dev team, etc.
        \Log::error("Failed to send welcome email to {$event->user->email}: {$exception->getMessage()}");
    }
}
Queue Worker Required

Queued listeners only run if you have a queue worker processing jobs. In development run php artisan queue:work. In production, use Supervisor (or systemd) to keep the worker alive, or Laravel Horizon if you're on Redis and want a dashboard for failed and pending jobs. Remember to run php artisan queue:restart on deploy, because long-running workers keep the old code in memory.

Queued Listeners and Transactions

If you dispatch an event inside DB::transaction(), a fast worker can pick up the queued listener before the transaction commits. The listener then tries to load the model and fails with ModelNotFoundException, or reads old data. Set public $afterCommit = true; on the listener (or after_commit in your queue connection config) so the job is only pushed once the commit happens.

Conditionally Queueing a Listener

Sometimes a listener should only be queued under certain conditions. Add a shouldQueue method; if it returns false, the listener isn't run at all:

Conditional Queueing
class SendWelcomeEmail implements ShouldQueue
{
    /**
     * Determine whether the listener should be queued.
     */
    public function shouldQueue(UserRegistered $event): bool
    {
        // Only queue if the user has a verified email
        return $event->user->email_verified_at !== null;
    }
}

Dispatching Events

There are three ways to dispatch an event, and they all end up in the same dispatcher. A few helper variants cover the conditional and deferred cases:

Dispatching Methods
use App\Events\UserRegistered;

// Method 1: Global helper (most common)
event(new UserRegistered($user));

// Method 2: Static dispatch on the event class (uses Dispatchable trait)
UserRegistered::dispatch($user);

// Method 3: Via the Event facade
use Illuminate\Support\Facades\Event;
Event::dispatch(new UserRegistered($user));

// Dispatch after the HTTP response has been sent to the browser
// Plus conditional variants
UserRegistered::dispatchAfterResponse($user);
UserRegistered::dispatchIf($user->isActive(), $user);
UserRegistered::dispatchUnless($user->isBanned(), $user);

Real-World Controller Example

app/Http/Controllers/Auth/RegisterController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Events\UserRegistered;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;

class RegisterController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name'     => 'required|string|max:255',
            'email'    => 'required|email|unique:users',
            'password' => 'required|min:8|confirmed',
        ]);

        $user = User::create([
            'name'     => $validated['name'],
            'email'    => $validated['email'],
            'password' => Hash::make($validated['password']),
        ]);

        // Fire the event; listeners handle everything that follows
        UserRegistered::dispatch($user);

        return redirect('/dashboard')->with('success', 'Welcome aboard!');
    }
}

The controller creates a user and redirects. That's it. If product asks for a referral bonus after signup next month, you add a listener and the controller doesn't change at all. The flip side: someone reading this controller can't see what happens after registration, so event:list becomes your map.

Event Subscribers

Once you have a handful of small listeners that all deal with the same area (user auth, say), a subscriber class lets you group them in one file. It has a subscribe method that maps each event to one of its own methods.

app/Listeners/UserEventSubscriber.php
<?php

namespace App\Listeners;

use App\Events\UserRegistered;
use App\Events\UserLoggedIn;
use App\Events\UserPasswordChanged;
use Illuminate\Events\Dispatcher;

class UserEventSubscriber
{
    public function handleUserRegistration(UserRegistered $event): void
    {
        // Send welcome email, create profile, etc.
    }

    public function handleUserLogin(UserLoggedIn $event): void
    {
        // Update last_login_at, log IP address
        $event->user->update(['last_login_at' => now()]);
    }

    public function handlePasswordChange(UserPasswordChanged $event): void
    {
        // Notify user via email, invalidate other sessions
    }

    /**
     * Register the listeners for the subscriber.
     */
    public function subscribe(Dispatcher $events): void
    {
        $events->listen(UserRegistered::class,   [self::class, 'handleUserRegistration']);
        $events->listen(UserLoggedIn::class,      [self::class, 'handleUserLogin']);
        $events->listen(UserPasswordChanged::class, [self::class, 'handlePasswordChange']);
    }
}

// Register in EventServiceProvider:
// protected $subscribe = [UserEventSubscriber::class];

Model Events: Observers

Laravel Eloquent models fire their own lifecycle events automatically: creating, created, updating, updated, saving, saved, deleting, deleted, restoring, and restored. To hook into several of them for one model, an Observer class keeps things in one place.

Generate and implement an Observer
# Generate observer
php artisan make:observer UserObserver --model=User
app/Observers/UserObserver.php
<?php

namespace App\Observers;

use App\Models\User;
use Illuminate\Support\Str;

class UserObserver
{
    /**
     * Handle the User "creating" event.
     * Fires BEFORE the model is saved to the database.
     */
    public function creating(User $user): void
    {
        // Auto-generate a public UUID for external use
        $user->uuid = Str::uuid();
    }

    /**
     * Handle the User "created" event.
     * Fires AFTER the model is saved.
     */
    public function created(User $user): void
    {
        // Create associated settings with defaults
        $user->settings()->create([
            'theme'            => 'light',
            'notifications'    => true,
            'emails_marketing' => false,
        ]);
    }

    /**
     * Handle the User "updating" event.
     */
    public function updating(User $user): void
    {
        // Track when the email was changed
        if ($user->isDirty('email')) {
            $user->email_verified_at = null;
        }
    }

    /**
     * Handle the User "deleting" event.
     */
    public function deleting(User $user): void
    {
        // Clean up related data before deletion
        $user->posts()->delete();
        $user->settings()->delete();
    }
}

// Register in AppServiceProvider::boot():
// User::observe(UserObserver::class);

Events vs Observers: When to Use Which

Scenario Use Events + Listeners Use Observers
Triggered by user actions (registration, checkout) ✅ Yes Possible but verbose
Reacting to model lifecycle (created, updated, deleted) Possible but extra boilerplate ✅ Yes, built for this
Multiple unrelated listeners ✅ Yes Gets messy
Auto-populate model fields before save Awkward ✅ Yes (creating/saving hooks)
Broadcasting to WebSocket channels ✅ Yes (ShouldBroadcast) No
Queuing heavy work (emails, API calls) ✅ Yes (ShouldQueue on listener) Requires manual job dispatch

Broadcasting Events Over WebSockets

The same event classes can be pushed to the browser through Laravel Echo and Pusher (or Soketi/Reverb). Implement ShouldBroadcast, define broadcastOn, and the event goes out to WebSocket clients when you dispatch it. Note that ShouldBroadcast goes through the queue, so nothing reaches the browser if no worker is running. Use ShouldBroadcastNow if you need it sent immediately.

app/Events/OrderStatusUpdated.php
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderStatusUpdated implements ShouldBroadcast
{
    use Dispatchable, SerializesModels;

    public Order $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }

    /**
     * Broadcast on the user's private channel so only they receive it.
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel("orders.{$this->order->user_id}"),
        ];
    }

    /**
     * Data sent to the client.
     */
    public function broadcastWith(): array
    {
        return [
            'order_id' => $this->order->id,
            'status'   => $this->order->status,
            'updated'  => $this->order->updated_at->toIso8601String(),
        ];
    }

    /**
     * Event name visible on the client side.
     */
    public function broadcastAs(): string
    {
        return 'order.updated';
    }
}

Laravel Echo: Listening in the Browser

resources/js/app.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
    forceTLS: true,
});

// Listen on the private channel for the logged-in user
Echo.private(`orders.${userId}`)
    .listen('.order.updated', (data) => {
        console.log('Order status changed:', data.status);
        showToast(`Your order #${data.order_id} is now ${data.status}`);
    });

Testing Events and Listeners

If your feature tests fire real events, they send real emails and hit real APIs. Event::fake() stops listeners from running but still records what was dispatched, so you can assert on it. That's what you want in controller tests. Test the listeners themselves separately.

tests/Feature/RegistrationTest.php
<?php

namespace Tests\Feature;

use App\Events\UserRegistered;
use App\Listeners\SendWelcomeEmail;
use App\Listeners\CreateUserProfile;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class RegistrationTest extends TestCase
{
    public function test_registration_fires_user_registered_event(): void
    {
        Event::fake();

        $response = $this->post('/register', [
            'name'                  => 'Jane Doe',
            'email'                 => 'jane@example.com',
            'password'              => 'secret1234',
            'password_confirmation' => 'secret1234',
        ]);

        $response->assertRedirect('/dashboard');

        // Assert the event was dispatched
        Event::assertDispatched(UserRegistered::class, function ($event) {
            return $event->user->email === 'jane@example.com';
        });
    }

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

        Event::assertListening(UserRegistered::class, SendWelcomeEmail::class);
        Event::assertListening(UserRegistered::class, CreateUserProfile::class);
    }
}

// Listener unit test: test it in isolation
class SendWelcomeEmailTest extends TestCase
{
    public function test_sends_welcome_email(): void
    {
        Mail::fake();

        $user = User::factory()->create();
        $event = new UserRegistered($user);

        (new SendWelcomeEmail)->handle($event);

        Mail::assertSent(WelcomeEmail::class, fn ($mail) =>
            $mail->hasTo($user->email)
        );
    }
}
Fake Selectively

Pass specific event classes to Event::fake([UserRegistered::class]) to fake only those and let the rest fire normally. This matters more than it looks: a bare Event::fake() also swallows Eloquent model events, so observers that set a UUID in creating stop running and factories can suddenly fail.

When I'd Use Events (and When I Wouldn't)

Events earn their place when one action has several side effects that don't depend on each other, and when some of them are slow enough to queue. Signup, checkout and payment webhooks are the usual candidates.

Trade-offs worth knowing

  • Skip events for a single side effect. If registration only ever sends one email, calling the mailer in the controller (or a queued job) is easier to read. An event with one listener is just indirection.
  • Don't use events for steps that must succeed together. If creating the profile row has to happen or the whole signup should fail, put it in the same transaction as the user insert. A queued listener can fail on its own while the user already exists.
  • Keep listeners independent. Listeners run in registration order, but relying on that order is fragile. If listener B needs listener A's result, that's one job, not two.
  • Be careful with observers. They don't fire on mass updates like User::where(...)->update() or on DB::table() queries, and that surprises people when "the observer stopped working".
  • Add failed() to queued listeners that matter, and check php artisan queue:failed after deploys. A failing listener doesn't break the request, so nobody notices otherwise.
  • Name events in the past tense (UserRegistered, OrderPlaced) and listeners as the action they take. It makes event:list read like documentation.

On a small app with simple flows, I wouldn't reach for events at all. Once a controller starts collecting unrelated "and also do this" lines, that's usually the point where moving them into listeners pays off.

Laravel Events Listeners Observer Pattern Queues PHP Backend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.