Skip to content
Muhammet Şafak
tr
Framework & Library 5 min read

Events and Listeners in Laravel

How I use Laravel's event system to decouple side effects from the main flow — with practical examples of what you gain.


What should happen when a user registers? Send a welcome email, log the activity, maybe notify a marketing service. Writing all of that inside UserController@register is easy at first — but a few months later that method becomes a bloated mess.

Laravel’s event and listener system exists precisely to separate these side effects from the registration action itself. This post walks through how I use it.

What is an event?

An event is a simple PHP class that represents something meaningful that happened in the application — UserRegistered, OrderPlaced, PasswordChanged, and so on. The event itself does no work; it just says “this happened” and carries the relevant data.

A listener is the class that reacts to that event and performs an action. A single event can have multiple listeners.

Example: user registration

First, let’s generate the event and its listeners:

php artisan make:event UserRegistered
php artisan make:listener SendWelcomeEmail --event=UserRegistered
php artisan make:listener LogUserActivity  --event=UserRegistered

The event class holds only the data it needs to carry:

<?php

namespace App\Events;

use App\User;
use Illuminate\Queue\SerializesModels;

class UserRegistered
{
    use SerializesModels;

    public User $user;

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

The first listener sends the welcome email:

<?php

namespace App\Listeners;

use App\Events\UserRegistered;

class SendWelcomeEmail
{
    public function handle(UserRegistered $event): void
    {
        \Mail::send('emails.welcome', ['user' => $event->user], function ($m) use ($event) {
            $m->to($event->user->email)
              ->subject('Hoşgeldiniz!');
        });
    }
}

The second listener records the activity:

<?php

namespace App\Listeners;

use App\Events\UserRegistered;
use App\ActivityLog;

class LogUserActivity
{
    public function handle(UserRegistered $event): void
    {
        ActivityLog::create([
            'user_id' => $event->user->id,
            'action'  => 'registered',
        ]);
    }
}

Wiring it up in the event service provider

Inside EventServiceProvider we map the event to its listeners:

<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        \App\Events\UserRegistered::class => [
            \App\Listeners\SendWelcomeEmail::class,
            \App\Listeners\LogUserActivity::class,
        ],
    ];
}

Firing the event from the controller

The controller now focuses solely on the registration itself and knows nothing about the side effects:

<?php

namespace App\Http\Controllers;

use App\Events\UserRegistered;
use App\User;
use Illuminate\Http\Request;

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

        $user = User::create([
            'email'    => $request->email,
            'password' => bcrypt($request->password),
        ]);

        event(new UserRegistered($user));

        return redirect('/')->with('mesaj', 'Kaydınız tamamlandı!');
    }
}

The single line event(new UserRegistered($user)) kicks everything off. The controller doesn’t need to know what happens next.

What do you gain from this structure?

When a new requirement arrives — say, sending a Slack notification after registration — I just write a new listener and register it in EventServiceProvider. The controller and the existing listeners are untouched.

The same is true in the other direction: if I need to change the email logic, I open SendWelcomeEmail and nothing else. It has zero coupling to the rest of the registration flow.

There is another practical benefit: by implementing the ShouldQueue interface, you can push the listener onto a queue so that, for example, the email is processed in the background. All it takes is one addition to the listener class:

use Illuminate\Contracts\Queue\ShouldQueue;

class SendWelcomeEmail implements ShouldQueue
{
    // ...
}

The controller does not change at all. That level of isolation would not be this straightforward without the decoupling the event system provides.

Testing listeners in isolation

There is also a testability win here. Because each listener is an independent class, you can test them individually. You can construct a real UserRegistered event by hand and drive the listener directly — no need to trigger the full registration flow over HTTP:

public function test_hosgeldin_epostasi_gonderilir()
{
    Mail::fake();

    $user = User::factory()->create();
    $listener = new SendWelcomeEmail();
    $listener->handle(new UserRegistered($user));

    Mail::assertSent(WelcomeMail::class, function ($mail) use ($user) {
        return $mail->hasTo($user->email);
    });
}

You are testing only whether the listener behaves correctly — completely isolated. That kind of isolation is simply not possible when the logic is buried inside a controller.

Event naming matters

Event class names become part of the application’s language. Past-tense names like UserRegistered, OrderShipped, and PaymentFailed clearly communicate “something happened” and make the system easier to understand at a glance. Names like DoSendEmail or HandleUserRegistration blur the line between an event and a listener and muddy the intent.

I did not pay enough attention to this early on and created a few poorly named event classes before realising that naming is a real signal for understanding the code. Naming an event to answer the question “what happened?” naturally makes it easier to name listeners to answer “what should happen when this does?”.

One pitfall to keep in mind

Overusing events can make tracing request flows difficult. If you start chaining every business step through a series of fired events, understanding the path a single request takes requires jumping across multiple files. The event system is a great tool for separating side effects; it is a poor tool for fragmenting the primary flow — and I try to keep that distinction in mind.

I have settled on a simple rule: if I am inside a controller method and I think “adding this here will bloat the method’s responsibility,” that is when the event system earns its place. But mandatory business steps within the same flow — like decrementing stock when an order is created — should not be pushed into a listener. If that step is missing, the operation cannot be considered complete; burying it in a listener makes tracing harder and complicates error handling. Events work best for side effects that are “nice to have but not required” or “could be added later.”

Tags: #Laravel
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind