How Laravel Events, Listeners, and Observers Actually Work (And When to Use Each)

php dev.to

Originally published at hafiz.dev


A new user registers on your SaaS. You need to send a welcome email, provision their free trial, log the signup event, and notify your Slack channel. Where does that code go?

If the answer is "the controller", your controller is doing too much. If the answer is "a listener that calls a listener that calls another listener", your event system is doing too much. Laravel gives you three distinct tools to get there: Events, Listeners, and Observers. Each one has a clear job. The distinction is worth understanding because reaching for the wrong one creates the kind of coupling you were trying to avoid in the first place.

What Problem Are We Actually Solving?

When a user registers, the naive approach stuffs everything in the controller:

public function store(RegisterRequest $request): RedirectResponse
{
    $user = User::create($request->validated());

    Mail::to($user)->send(new WelcomeEmail($user));
    Trial::provision($user);
    Slack::notify('New signup: ' . $user->email);
    Log::info('User registered', ['id' => $user->id]);

    return redirect('/dashboard');
}
Enter fullscreen mode Exit fullscreen mode

This works. It's also a problem. The controller now knows about mail, trials, Slack, and logging. Add a new onboarding step and you touch the controller. Change how trials work and you touch the controller. Write a test for the controller and you mock four different things.

Events, Listeners, and Observers flip this around. The controller fires a signal ("something happened") and the rest of the application reacts. The controller doesn't know or care what reacts.

Events: The Signal

An event is a plain PHP class that represents something that happened. That's it. It carries data about the occurrence and nothing else.

<?php

namespace App\Events;

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

class UserRegistered
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public readonly User $user
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Create one with Artisan:

php artisan make:event UserRegistered
Enter fullscreen mode Exit fullscreen mode

Fire it anywhere in your application:

UserRegistered::dispatch($user);
// or
event(new UserRegistered($user));
Enter fullscreen mode Exit fullscreen mode

An event class should be small. It shouldn't contain methods that do things. It's a data container, not a service. The name should describe what happened in past tense: UserRegistered, OrderShipped, PaymentFailed. If you find yourself writing logic inside an event class, that logic belongs in a listener.

Listeners: What Reacts

A listener receives an event and does something with it. One event can have many listeners. Listeners don't know about each other.

php artisan make:listener SendWelcomeEmail --event=UserRegistered
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Listeners;

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

class SendWelcomeEmail
{
    public function handle(UserRegistered $event): void
    {
        Mail::to($event->user)->send(new WelcomeEmail($event->user));
    }
}
Enter fullscreen mode Exit fullscreen mode

Each listener does one job. SendWelcomeEmail sends a welcome email. ProvisionFreeTrial provisions a trial. NotifySlack posts to Slack. Adding a new step means adding a new listener. You don't touch the existing ones.

Auto-Discovery in Laravel 11+

Before Laravel 11, you had to manually register every event and listener in EventServiceProvider::$listen. Laravel 11 removed EventServiceProvider from the default application structure and turned on auto-discovery by default.

Auto-discovery works by scanning app/Listeners/ and looking for handle() methods. If a handle() method type-hints an event class, Laravel automatically wires that listener to the event. No registration required.

// This listener is auto-discovered. No registration needed.
class SendWelcomeEmail
{
    public function handle(UserRegistered $event): void
    {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

This matters because the old approach had a subtle maintenance problem: the $listen array in EventServiceProvider was a second source of truth. You could create a listener, forget to register it, and your code would run without errors. The listener just silently never fired. Auto-discovery eliminates that category of bug entirely.

One listener class can also handle multiple events by defining multiple methods, each type-hinting a different event:

class UserActivityListener
{
    public function handleLogin(Login $event): void
    {
        // Runs on login
    }

    public function handleLogout(Logout $event): void
    {
        // Runs on logout
    }
}
Enter fullscreen mode Exit fullscreen mode

Laravel's scanner picks up both handleLogin and handleLogout automatically because they start with handle and type-hint an event class.

In production, cache the discovered listener manifest so Laravel doesn't scan on every request:

php artisan event:cache
Enter fullscreen mode Exit fullscreen mode

Clear it during deployment with:

php artisan event:clear
Enter fullscreen mode Exit fullscreen mode

If you're on an older Laravel version or want explicit control, you can still register listeners in AppServiceProvider::boot():

use Illuminate\Support\Facades\Event;

public function boot(): void
{
    Event::listen(
        UserRegistered::class,
        SendWelcomeEmail::class
    );
}
Enter fullscreen mode Exit fullscreen mode

Both approaches work. Auto-discovery is cleaner for new applications. Explicit registration is useful when you need listeners from third-party packages or conditional registration.

Queued Listeners

Sending emails, making HTTP calls, generating reports: these don't need to block the HTTP response. Implement ShouldQueue and Laravel automatically dispatches the listener as a background queue job:

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;

class SendWelcomeEmail implements ShouldQueue
{
    use InteractsWithQueue;

    public string $queue = 'emails';
    public int $delay = 30; // seconds

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

    public function failed(UserRegistered $event, \Throwable $exception): void
    {
        // Handle failure: log it, alert, retry logic, etc.
        Log::error('Welcome email failed', [
            'user' => $event->user->id,
            'error' => $exception->getMessage(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The failed() method is important. Queued listeners can fail. Define this method to handle failures gracefully rather than silently losing the email send.

After Database Commit

One common gotcha: a listener fires before the database transaction commits. Your listener reads a user ID, queries the database, and finds nothing because the record doesn't exist yet.

The fix is ShouldHandleEventsAfterCommit:

use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;

class ProvisionFreeTrial implements ShouldQueue, ShouldHandleEventsAfterCommit
{
    public function handle(UserRegistered $event): void
    {
        // Guaranteed to run only after the database transaction commits
        Trial::createFor($event->user);
    }
}
Enter fullscreen mode Exit fullscreen mode

Use this whenever your listener reads from the database and you're dispatching the event inside a transaction.

Observers: Model Lifecycle Hooks

Observers are a different tool for a different job. While events and listeners handle application-level signals, observers handle Eloquent model lifecycle events: creating, created, updating, updated, deleting, deleted, and more.

php artisan make:observer UserObserver --model=User
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Observers;

use App\Models\User;

class UserObserver
{
    public function created(User $user): void
    {
        // Runs every time any User is created anywhere in the codebase
    }

    public function updated(User $user): void
    {
        // Runs every time any User is updated
    }

    public function deleted(User $user): void
    {
        // Runs every time any User is deleted
    }
}
Enter fullscreen mode Exit fullscreen mode

Register the observer on the model using the #[ObservedBy] PHP attribute, introduced in Laravel 10.44 and fully supported in Laravel 11, 12, and 13:

<?php

namespace App\Models;

use App\Observers\UserObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Foundation\Auth\User as Authenticatable;

#[ObservedBy([UserObserver::class])]
class User extends Authenticatable
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Before this attribute existed, you'd register observers in a service provider:

// AppServiceProvider::boot()
User::observe(UserObserver::class);
Enter fullscreen mode Exit fullscreen mode

Both still work. The #[ObservedBy] attribute is cleaner because the registration lives on the model itself. You can see at a glance that UserObserver is active without hunting through providers.

The Full List of Observer Methods

An observer class can define methods for any of the Eloquent lifecycle events:

class UserObserver
{
    public function retrieved(User $user): void {}   // After fetching from DB
    public function creating(User $user): void {}    // Before insert
    public function created(User $user): void {}     // After insert
    public function updating(User $user): void {}    // Before update
    public function updated(User $user): void {}     // After update
    public function saving(User $user): void {}      // Before create or update
    public function saved(User $user): void {}       // After create or update
    public function deleting(User $user): void {}    // Before delete
    public function deleted(User $user): void {}     // After delete
    public function restoring(User $user): void {}   // Before soft-restore
    public function restored(User $user): void {}    // After soft-restore
}
Enter fullscreen mode Exit fullscreen mode

You don't need to define all of them. Define only the lifecycle hooks your use case actually needs. An observer with one method is perfectly fine.

The creating and updating hooks (before the operation) are useful for validation, transformations, or cancelling the operation by returning false. The created and updated hooks (after the operation) are better for side effects like sending notifications or clearing caches, since you know the database state is settled.

What Goes in an Observer vs a Listener

This is where most developers get confused. The distinction is simpler than it looks:

Use an observer when the behavior should trigger on every instance of a model event, everywhere in the codebase. Audit logging is the clearest example: every time any user is created, updated, or deleted, you want a log entry. An observer is the right place for that.

Use an event and listener when the behavior is specific to a particular business flow. A user registering via the web form should get a welcome email. A user created programmatically by a data import job probably shouldn't. Events give you control over when to fire the signal. Observers fire automatically no matter what.

Here's a practical SaaS breakdown:

Scenario Tool Why
Send welcome email on registration Event + Listener Only fires when you explicitly dispatch the event
Write to audit log on every User update Observer Should always fire, regardless of where the update originates
Provision free trial after signup Event + Listener (queued) Business flow specific, benefits from queueing
Clear cache when Post is deleted Observer Should always happen when any Post is deleted
Notify Slack on first payment Event + Listener Specific business milestone, not every payment creation
Update last_updated_at on every Order save Observer Always should happen, tightly coupled to model lifecycle

If you're still unsure which to reach for, this decision flow covers most cases:

View the interactive diagram on hafiz.dev

A Real-World Pattern: User Registration

Here's how all three tools work together in a user registration flow. The controller fires one event. Two listeners react to that event asynchronously. The observer independently handles model-level concerns for every user creation, no matter where it originates.

// Controller stays clean
class RegisterController extends Controller
{
    public function store(RegisterRequest $request): RedirectResponse
    {
        $user = User::create($request->validated());

        UserRegistered::dispatch($user);

        return redirect('/dashboard');
    }
}
Enter fullscreen mode Exit fullscreen mode
// Listener: sends welcome email (queued)
class SendWelcomeEmail implements ShouldQueue
{
    public function handle(UserRegistered $event): void
    {
        Mail::to($event->user)->send(new WelcomeEmail($event->user));
    }
}

// Listener: provisions trial (queued, after commit)
class ProvisionFreeTrial implements ShouldQueue, ShouldHandleEventsAfterCommit
{
    public function handle(UserRegistered $event): void
    {
        Trial::createFor($event->user);
    }
}
Enter fullscreen mode Exit fullscreen mode
// Observer: handles model-level concerns for ALL user creation
#[ObservedBy([UserObserver::class])]
class User extends Authenticatable { /* ... */ }

class UserObserver
{
    public function created(User $user): void
    {
        AuditLog::record('user.created', $user->id);
    }

    public function updated(User $user): void
    {
        AuditLog::record('user.updated', $user->id, $user->getDirty());
    }
}
Enter fullscreen mode Exit fullscreen mode

The controller fires one event. Two listeners react to that event in the background. The observer independently logs every user creation, including the one triggered by the controller. Each piece of code does one job and doesn't know about the others.

This pattern scales well in multi-tenant SaaS applications, where the same model events fire across tenants and the observer ensures audit logging is consistent regardless of which flow created the record.

Testing Events and Listeners

Laravel's Event::fake() replaces the event dispatcher with a fake that captures dispatched events without actually running listeners. This is what you want for most feature tests. You want to assert that an event was dispatched, not that a listener ran.

use App\Events\UserRegistered;
use Illuminate\Support\Facades\Event;

test('user registration dispatches UserRegistered event', function () {
    Event::fake();

    $response = $this->post('/register', [
        'name' => 'Hafiz Riaz',
        'email' => 'hafiz@example.com',
        'password' => 'password',
        'password_confirmation' => 'password',
    ]);

    Event::assertDispatched(UserRegistered::class, function ($event) {
        return $event->user->email === 'hafiz@example.com';
    });
});
Enter fullscreen mode Exit fullscreen mode

Test the listener separately by instantiating it directly:

use App\Events\UserRegistered;
use App\Listeners\SendWelcomeEmail;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeEmail;

test('SendWelcomeEmail listener sends welcome email', function () {
    Mail::fake();

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

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

    Mail::assertSent(WelcomeEmail::class, fn ($mail) => $mail->hasTo($user->email));
});
Enter fullscreen mode Exit fullscreen mode

For observers, Event::fake() silences them by default. Model events don't fire when the dispatcher is faked. If you need observers to run inside a fake context, use Event::fakeFor():

test('observer logs user creation', function () {
    // Events are faked, so observers don't run here
    $order = Event::fakeFor(function () {
        return Order::factory()->create();
        // Event::assertDispatched() checks happen inside fakeFor
    });

    // After fakeFor, events and observers run normally
    $order->update(['status' => 'shipped']);
    // Observer fires here
});
Enter fullscreen mode Exit fullscreen mode

You can also fake only specific events, leaving others to run normally:

Event::fake([UserRegistered::class]);
// Only UserRegistered is faked; all other events fire as usual
Enter fullscreen mode Exit fullscreen mode

Artisan Commands Reference

# Create
php artisan make:event UserRegistered
php artisan make:listener SendWelcomeEmail --event=UserRegistered
php artisan make:observer UserObserver --model=User

# List all registered events and listeners
php artisan event:list

# Cache discovered events (run on deployment)
php artisan event:cache

# Clear the event cache
php artisan event:clear
Enter fullscreen mode Exit fullscreen mode

You can find the full list of available Artisan commands in the Laravel Artisan Commands reference.

Common Mistakes

Putting business logic in events. Events are data containers. If you have methods in your event class that query the database or send emails, move that logic to a listener. Keeping events lean also makes them serializable, which matters for queued listeners. Laravel needs to serialize the event to pass it to the queue worker.

Using observers for flow-specific behavior. Observers fire on every model event everywhere. If you want an email sent only when a user registers via the web form, use an event that you dispatch explicitly, not an observer that fires every time a User record is created (including imports, seeds, and tests). Observers are for behavior that should always fire regardless of the origin of the change.

Forgetting event:cache in production. Auto-discovery scans the filesystem on every request unless you cache the manifest. Always run php artisan event:cache during deployment. If you're using Laravel Forge, add it to your deployment script. If you're using a CI/CD pipeline, add it after the composer install step.

Not defining failed() on queued listeners. Queued listeners can fail silently. Define the failed() method to handle errors: log them, send alerts, or retry with different parameters. A queued listener that throws an exception will be retried based on your queue configuration, but without a failed() handler you have no visibility into what failed.

Dispatching events inside database transactions without ShouldHandleEventsAfterCommit. If your listener reads data that the transaction hasn't committed yet, it will fail in subtle ways. Always add ShouldHandleEventsAfterCommit when your listener queries data that the same transaction creates.

Testing with Event::fake() and expecting observers to run. When you call Event::fake(), model observers are also silenced because they rely on the event system internally. If your test needs observer behavior, either use Event::fakeFor() for the specific section that shouldn't fire observers, or don't fake events for the part of the test where observer behavior matters.

FAQ

Should I use Events or Jobs for background tasks?

Both can run code in the background, but the semantics differ. Events signal that something happened and multiple listeners can react. Jobs represent a specific unit of work with one purpose. Use events when you have multiple things that need to react to an occurrence. Use jobs when you have one specific task to run.

Can one listener handle multiple events?

Yes. Type-hint multiple event classes in separate handle* methods, or use union types in a single method. With auto-discovery, Laravel registers each handle* method as a listener for the event it type-hints.

When should I fire an event vs call a method directly?

Fire an event when you want to decouple the caller from what happens next, especially when multiple things need to react, or when the reactions might change in the future. Call a method directly when it's a single, always-present behavior that's tightly coupled to the action.

Do observer methods run inside database transactions?

By default, observer methods run inside the same transaction as the Eloquent operation. If you need the observer to run only after the transaction commits, implement ShouldHandleEventsAfterCommit on the observer class.

What happens to queued listeners if the application crashes before they run?

They stay in the queue. As long as you're using a persistent queue driver like Redis or database, queued jobs survive application restarts. This is one of the advantages of queuing over synchronous execution.

Conclusion

Events signal that something happened. Listeners react to those signals. Observers hook into the Eloquent model lifecycle automatically.

The practical rule: if you want behavior to fire only when you choose to fire it, use events. If you want behavior to fire every time a model changes no matter what, use observers. When in doubt, events with explicit dispatch give you more control.

Think about it from the perspective of a new developer joining your codebase. If they see a UserRegistered::dispatch($user) in the controller, they know exactly where to look for what happens next: the app/Listeners/ directory, or a quick php artisan event:list. If they see an observer on the User model, they know that code runs on every user lifecycle event regardless of where it originates. Both are discoverable. Both have clear intent. That's the point.

The modern Laravel 11+ setup makes this easier. Auto-discovery removes the registration boilerplate. #[ObservedBy] keeps observer registration on the model where it belongs. ShouldHandleEventsAfterCommit handles the transaction timing edge cases that have caught developers off guard for years. And Event::fake() makes the whole system testable without running real side effects.

Start with events and listeners for business flows. Add observers for model lifecycle concerns that should always fire. Keep each piece small and focused. The system scales from a single controller action to a multi-tenant SaaS without the architecture needing to change.

Building something that needs this architecture across a complex codebase? Get in touch.

Source: dev.to

arrow_back Back to Tutorials