Clean Eloquent Architecture: Handling Events Cleanly with Laravel Observers

How to handle Events cleanly in Laravel

How to handle Events cleanly in Laravel


As Laravel applications grow, Eloquent models often start doing more than just interacting with the database. We may add logic for sending notifications, creating audit records, updating related data, clearing caches, dispatching jobs, or performing other actions whenever a model changes.

It is easy to put all of this logic directly inside the model using methods such as booted(). But as the application grows, the model can quickly become difficult to maintain.

This is where Laravel Eloquent Observers become useful.

Observers allow us to move model event-related logic into dedicated classes, keeping our models cleaner and making the overall application structure easier to understand.

In this article, we will learn how Laravel observers work, how to create and register them, how to handle different Eloquent events, and some practical patterns for keeping observer logic clean.

What are Laravel Eloquent Observers?

Laravel Eloquent models dispatch events during different stages of their lifecycle. For example, when a model is created, updated, deleted, restored, or retrieved.

Some commonly used Eloquent model events are:

  • retrieved
  • creating
  • created
  • updating
  • updated
  • saving
  • saved
  • deleting
  • deleted
  • restoring
  • restored
  • forceDeleted
  • replicating

An observer is simply a class where methods responds to these model events.

For example, if we create a UserObserver, we can define methods such as created(), updated(), and deleted().

Why Should We Use Observers?

Let's first see what can happen when we keep too much event-related logic inside a model.

For example:

<?php

namespace App\Models;

use App\Jobs\SendWelcomeEmail;
use App\Models\AuditLog;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected static function booted(): void
    {
        static::created(function (User $user) {
            AuditLog::create([
                'user_id' => $user->id,
                'action' => 'User created',
            ]);

            SendWelcomeEmail::dispatch($user);
        });

        static::updated(function (User $user) {
            AuditLog::create([
                'user_id' => $user->id,
                'action' => 'User updated',
            ]);
        });

        static::deleted(function (User $user) {
            AuditLog::create([
                'user_id' => $user->id,
                'action' => 'User deleted',
            ]);
        });
    }
}


This code works, but imagine adding more business logic, more events, and more dependencies over time. The model can quickly become difficult to read.

Instead, we can move this logic into a dedicated observer.

Creating an Eloquent Observer

Laravel provides an Artisan command to generate an observer. This will create a observer as below

php artisan make:observer UserObserver --model=User
app/Observers/UserObserver.php

The generated observer can contain methods for different model events.

Basic UserObserver Example

Let's create a simple observer for our User model.

<?php

namespace App\Observers;

use App\Models\User;

class UserObserver
{
    public function created(User $user): void
    {
        // User was created
    }

    public function updated(User $user): void
    {
        // User was updated
    }

    public function deleted(User $user): void
    {
        // User was deleted
    }
}


Each method receives the affected model as its argument. For example, when a User is created, Laravel calls the created() method.

Registering the Observer

Creating an observer is not enough. Laravel also needs to know that the observer should listen to the model. There are multiple ways to register an observer in modern Laravel applications.

Using the ObservedBy Attribute

One clean approach is to use Laravel's ObservedBy attribute on the model.

<?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
{
    //
}


Now Laravel knows that UserObserver should observe the User model. This approach keeps the relationship between the model and its observer visible directly on the model.

Registering the Observer in AppServiceProvider

You can also manually register an observer using the observe() method. For example, inside AppServiceProvider:

<?php

namespace App\Providers;

use App\Models\User;
use App\Observers\UserObserver;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        User::observe(UserObserver::class);
    }
}


This explicitly tells Laravel to register UserObserver for the User model. Both approaches are valid. For a modern Laravel application, using the ObservedBy attribute is a clean option when you want the observer relationship declared directly on the model.

Handling the created Event

Let's say we want to create an audit record whenever a user is created. We can handle this inside the observer:

public function created(User $user): void
{
    AuditLog::create([
        'user_id' => $user->id,
        'action' => 'User created',
    ]);
}


Now whenever a user is successfully created through Eloquent, the created() observer method can execute. This keeps the audit-related logic out of the controller and the model.

Handling the updated Event

We can use the updated() method when we need to react after an existing model has been updated.

public function updated(User $user): void
{
    if ($user->wasChanged('email')) {
        // Email address was changed
    }
}


This is useful when the observer should perform an action only when a particular attribute changes.

For example, you could dispatch a job when the user's email address changes.

Handling the updating Event

There is an important difference between updating and updatedThe updating event is fired before the changes are persisted, while updated is fired after the model has been updated.

This means updating is useful when you need to inspect or modify the model before the database update occurs.

public function updating(User $user): void
{
    if ($user->isDirty('email')) {
        // Email is about to change
    }
}


For example, isDirty() can be useful here because the model's new value has not yet been persisted.

Handling the deleted Event

Observers can also handle deletion.

public function deleted(User $user): void
{
    AuditLog::create([
        'user_id' => $user->id,
        'action' => 'User deleted',
    ]);
}


This method can execute after the model has been deleted. If your application uses soft deletes, you can also handle events such as restored and forceDeleted when required.

Before and After: Keeping the Model Clean

Let's compare two approaches.

Putting Everything in the Model

class User extends Model
{
    protected static function booted(): void
    {
        static::created(function (User $user) {
            // Create audit log
            // Send notification
            // Dispatch job
            // Update other records
        });

        static::updated(function (User $user) {
            // More logic...
        });

        static::deleted(function (User $user) {
            // More logic...
        });
    }
}


This may be fine for very small applications. But as more events are added, the model becomes responsible for too many things.

Moving Event Logic to an Observer

class UserObserver
{
    public function created(User $user): void
    {
        // Create audit log
        // Send notification
        // Dispatch job
    }

    public function updated(User $user): void
    {
        // Handle update
    }

    public function deleted(User $user): void
    {
        // Handle deletion
    }
}


Now the model remains focused primarily on representing the database entity and its relationships, while the observer handles lifecycle-related behaviour.

Keep Observers Focused

Using observers does not automatically make an application clean. You can still create a huge observer containing hundreds of lines of business logic.

A good observer should generally act as a coordinator rather than becoming a place for every piece of business logic.

For example, avoid doing this:

public function created(Order $order): void
{
    // 100+ lines of business logic...
}


Instead, move complex operations into dedicated services or jobs.

For example:

public function created(Order $order): void
{
    ProcessNewOrder::dispatch($order);
}


Now the observer has a very clear responsibility: when an order is created, start the process that should happen after creation.

Using Jobs with Observers

Observers are often a good place to dispatch jobs for expensive operations. For example, suppose creating an order requires generating a PDF invoice.

namespace App\Observers;

use App\Jobs\GenerateInvoice;
use App\Models\Order;

class OrderObserver
{
    public function created(Order $order): void
    {
        GenerateInvoice::dispatch($order);
    }
}


The observer remains small, while the actual invoice generation happens inside the job. This can make the request faster because expensive work can be handled by Laravel's queue system.

Observers and Database Transactions

This is an important consideration when using observers.

Suppose an order is created inside a database transaction and the observer dispatches some external work immediately.

If the transaction later rolls back, the observer's external action may already have happened even though the database record no longer exists.

Laravel provides ShouldHandleEventsAfterCommit for observers that should only handle their events after the surrounding database transaction has successfully committed.

For example:

<?php

namespace App\Observers;

use App\Jobs\GenerateInvoice;
use App\Models\Order;
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;

class OrderObserver implements ShouldHandleEventsAfterCommit
{
    public function created(Order $order): void
    {
        GenerateInvoice::dispatch($order);
    }
}


With this interface, the observer's event handlers are deferred until the database transaction commits successfully. If no transaction is active, the handler executes normally.

This can be particularly useful when observers trigger jobs, notifications, integrations, or other operations that depend on committed database data.

Don't Put Everything in an Observer

One common mistake is to assume that every action related to a model belongs inside its observer.

That is not necessarily true.

For example, if a user clicks a button to send a password reset email, that action is not necessarily a consequence of a database model event. It is part of an application workflow. In such cases, a controller, service, action class, or application event may be more appropriate.

Observers are best suited for behaviour that is naturally tied to the lifecycle of a model.

Observer vs Event and Listener

Laravel also has regular application events and listeners, so it is useful to understand the difference.

Approach Best Used For
Model Observer Logic directly related to an Eloquent model lifecycle
Event + Listener Application-level events and decoupled workflows
Service / Action Class Complex business operations that are explicitly triggered
Job Expensive or asynchronous work

For example, UserObserver::created() makes sense for something that should happen whenever a user is created through Eloquent.

But if you have a complex registration workflow containing multiple business steps, an application service or action class may provide a clearer structure.

Be Careful with Observer Side Effects

Observers are powerful because they run automatically. But that is also something you need to be careful about.

Consider this example:

public function updated(User $user): void
{
    $user->profile->update([
        'last_updated_at' => now(),
    ]);
}


Updating the profile could itself trigger another observer. If observers update other models, it is possible to create complicated chains of side effects.

This is why observer logic should be kept small, predictable, and carefully designed.

Mass Updates Do Not Trigger Model Events

Another important Eloquent behaviour is that mass updates and deletes do not dispatch the individual model events for the affected models because the models are not retrieved one by one.

For example:

User::where('status', 'inactive')
    ->update([
        'archived' => true,
    ]);

You should not expect the updated() method of your UserObserver to execute for every affected user in this case.

This is important when your observer performs critical side effects.

Testing Observer Logic

Since observers contain application behaviour, they should also be covered by tests when the behaviour is important.

For example, if creating an order should dispatch an invoice generation job, you can test that behaviour:

use App\Jobs\GenerateInvoice;
use App\Models\Order;
use Illuminate\Support\Facades\Queue;

Queue::fake();

Order::factory()->create();

Queue::assertPushed(GenerateInvoice::class);


This verifies that the observer-related behaviour results in the expected job being dispatched.

Best Practices for Laravel Observers

Here are some practical rules that can help keep observers maintainable.

1. Keep Observers Small

An observer should not become a dumping ground for business logic. Keep event handlers focused and delegate complex work to services, actions, or jobs.

2. Use Descriptive Event Methods

Use the appropriate lifecycle event for the job. For example, use created() when the record has already been persisted and creating() when you need to work before the insert.

3. Be Careful with External Side Effects

If an observer sends emails, calls APIs, dispatches jobs, or communicates with external services, consider whether the action should happen only after the database transaction commits.

4. Avoid Circular Updates

Be careful when an observer updates another model that has its own observer. This can create unexpected chains of events or even recursive behaviour.

5. Remember That Mass Operations Behave Differently

Don't assume that model observers will run for mass updates or deletes.

6. Don't Hide Major Business Workflows

If a business operation needs to explicitly happen because of a user action, a service or action class may be clearer than hiding the workflow inside an observer.

A Clean Observer Example

Let's put everything together with a simple order example. Suppose we want to generate an invoice whenever an order is created.

Our observer can remain very small:

<?php

namespace App\Observers;

use App\Jobs\GenerateInvoice;
use App\Models\Order;
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;

class OrderObserver implements ShouldHandleEventsAfterCommit
{
    public function created(Order $order): void
    {
        GenerateInvoice::dispatch($order);
    }
}


Then register it on the model:

<?php

namespace App\Models;

use App\Observers\OrderObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;

#[ObservedBy([OrderObserver::class])]
class Order extends Model
{
    //
}


Now the responsibilities are clearly separated:

  • Order represents the Eloquent model.
  • OrderObserver handles the model lifecycle event.
  • GenerateInvoice handles the potentially expensive work.

This is much easier to maintain than putting all of the invoice-generation logic directly inside the model.

Conclusion:

Laravel Eloquent Observers are a useful way to keep model event handling organized. They allow you to move lifecycle-related logic out of your models and into dedicated classes.

Instead of putting large amounts of logic inside booted(), you can create an observer and handle events such as created(), updated(), deleted(), and restored() in a structured way.

The important thing is not to move everything into an observer blindly. Keep observers focused on model lifecycle behaviour and delegate complex operations to services, actions, or jobs.

Also remember that observers are triggered by individual Eloquent model operations, while mass updates and deletes do not dispatch the corresponding model events for each affected record.

When used properly, observers can help keep your Laravel models clean, controllers smaller, and application behaviour easier to understand. 🚀

Thank you for reading this article 😊

For any query, do not hesitate to comment 💬



Previous Post Next Post

Contact Form