Laravel Events and Listeners
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.
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.
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).
Generate the Event class
Run php artisan make:event UserRegistered to create app/Events/UserRegistered.php.
Generate the Listener class
Run php artisan make:listener SendWelcomeEmail --event=UserRegistered to create app/Listeners/SendWelcomeEmail.php pre-typed to your event.
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.
Dispatch the event
Call event(new UserRegistered($user)) or UserRegistered::dispatch($user) from anywhere in your application.
The Event Class
<?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;
}
}
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
<?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
<?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,
],
];
}
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.
<?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()}");
}
}
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.
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:
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:
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
<?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.
<?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 observer
php artisan make:observer UserObserver --model=User
<?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.
<?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
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.
<?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)
);
}
}
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 onDB::table()queries, and that surprises people when "the observer stopped working". - Add
failed()to queued listeners that matter, and checkphp artisan queue:failedafter 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 makesevent:listread 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.