A guide to Laravel's model events

Model events are a really handy feature in Laravel that can help you to automatically run logic when certain actions are performed on your Eloquent models. But they can sometimes lead to weird side effects if they're not used correctly.

In this article, we're going to look at what model events are and how to use them in your Laravel application. We'll also look at how to test your model events and some of the gotchas to be aware of when using them. Finally, we'll take a look at some alternative approaches to model events that you might want to consider using.

What are Events and Listeners?

You may have already heard of "events" and "listeners". But if you haven't, here's a quick summary of what they are:

Events

These are things that happen in your application that you want to act on—for example, a user registering on your site, a user logging in, etc.

Typically, in Laravel, events are PHP classes. Apart from events provided by the framework or third-party packages, they're usually kept in the app/Events directory.

Here's an example of a simple event class that you might want to dispatch whenever a user registers on your site:

declare(strict_types=1);

namespace App\Events;

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

final class UserRegistered
{
    use Dispatchable;
    use InteractsWithSockets;
    use SerializesModels;

    public function __construct(public User $user)
    {
        //
    }
}

In the basic example above, we have an App\Events\UserRegistered event class that accepts a User model instance in its constructor. This event class is a simple data container that holds the user instance that was registered.

When dispatched, the event will trigger any listeners that are listening for it.

Here's a simple example of how you might dispatch that event when a user registers:

use App\Events\UserRegistered;
use App\Models\User;

$user = User::create([
    'name' => 'Eric Barnes',
    'email' => 'eric@example.com',
]);

UserRegistered::dispatch($user);

In the example above, we're creating a new user and then dispatching the App\Events\UserRegistered event with the user instance. Assuming the listeners are registered correctly, this will trigger any listeners that are listening for the App\Events\UserRegistered event.

Listeners

Listeners are blocks of code that you want to run when a specific event occurs.

For instance, sticking with our user registration example, you might want to send a welcome email to the user when they register. You could create a listener that listens for the App\Events\UserRegistered event and sends the welcome email.

In Laravel, listeners are typically (but not always - we'll cover this later) classes found in the app/Listeners directory.

An example of a listener that sends a welcome email to a user when they register might look like this:

declare(strict_types=1);

namespace App\Listeners;

use App\Events\UserRegistered;
use App\Notifications\WelcomeNotification;
use Illuminate\Support\Facades\Mail;

final readonly class SendWelcomeEmail
{
    public function handle(UserRegistered $event): void
    {
        $event->user->notify(new WelcomeNotification());
    }
}

As we can see in the code example above, the App\Listeners\SendWelcomeEmail listener class has a handle method that accepts an App\Events\UserRegistered event instance. This method is responsible for sending a welcome email to the user.

For a more in-depth explanation of events and listeners, you might want to check out the official documentation: https://laravel.com/docs/11.x/events

What are Model Events?

In your Laravel applications, you'll typically need to manually dispatch events when certain actions occur. As we saw in our example above, we can use the following code to dispatch an event:

UserRegistered::dispatch($user);

However, when working with Eloquent models in Laravel, there are some events which are automatically dispatched for us, so we don't need to manually dispatch them. We just need to define listeners for them if we want to perform an action when they occur.

The list below shows the events are automatically dispatched by Eloquent models along with their triggers:

In the list above, you may notice some of the event names are similar; for example, creating and created. The events ended in ing are performed before the action occurs and the changes are persisted in the database. Whereas the events ended in ed are performed after the action occurs and the changes are persisted in the database.

Let's take a look at how we can use these model events in our Laravel applications.

Listening to Model Events Using dispatchesEvents

One way to listen to model events is by defining a dispatchesEvents property on your model.

This property allows you to map Eloquent model events to the event classes that should be dispatched when the event occurs. This means you can then define your listeners as you would with any other event.

To provide more context, let's take a look at an example.

Imagine we are building a blogging application that has two models: App\Models\Post and App\Models\Author. We'll say both of these models support soft deletes. When we save a new App\Models\Post, we want to calculate the reading time of the post based on the length of the content. When we soft-delete an author, we want to soft-delete all the author's posts.

Setting Up the Models

We might have an App\Models\Author model that looks like so:

declare(strict_types=1);

namespace App\Models;

use App\Events\AuthorDeleted;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

final class Author extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $dispatchesEvents = [
        'deleted' => AuthorDeleted::class,
    ];

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}

In the model above, we have:

Now let's create our App\Models\Post model:

declare(strict_types=1);

namespace App\Models;

use App\Events\PostSaving;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;

final class Post extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $dispatchesEvents = [
        'saving' => PostSaving::class,
    ];

    public function author(): BelongsTo
    {
        return $this->belongsTo(Author::class);
    }
}

In the App\Models\Post model above, we have:

Our models are now prepared, so let's create our App\Events\AuthorDeleted and App\Events\PostSaving event classes.

Creating the Event Classes

We will create an App\Events\PostSaving event class that will be dispatched when a new post is being saved:

declare(strict_types=1);

namespace App\Events;

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

final class PostSaving
{
    use Dispatchable;
    use InteractsWithSockets;
    use SerializesModels;

    public function __construct(public Post $post)
    {
        //
    }
}

In the code above, we can see the App\Events\PostSaving event class that accepts an App\Models\Post model instance in its constructor. This event class is a simple data container that holds the post instance that is being saved.

Similarly, we can create an App\Events\AuthorDeleted event class that will be dispatched when an author is deleted:

declare(strict_types=1);

namespace App\Events;

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

final class AuthorDeleted
{
    use Dispatchable;
    use InteractsWithSockets;
    use SerializesModels;

   public function __construct(public Author $author)
   {
       //
   }
}

In the App\Events\AuthorDeleted class above, we can see that the constructor accepts an App\Models\Author model instance.

Now we can move on to creating our listeners.

Creating the Listeners

Let's first create a listener that can be used to calculate the estimated reading time of a post.

We'll create a new App\Listeners\CalculateReadTime listener class:

declare(strict_types=1);

namespace App\Listeners;

use App\Events\PostSaving;
use Illuminate\Support\Str;

final readonly class CalculateReadTime
{
    public function handle(PostSaving $event): void
    {
        $event->post->read_time_in_seconds = (int) ceil(
            (Str::wordCount($event->post->content) / 265) * 60
        );
    }
}

As we can see in the code above, we've got a single handle method. This is the method that will automatically be called when the App\Events\PostSaving event is dispatched. It accepts an instance of the App\Events\PostSaving event class which contains the post that is being saved.

In the handle method, we're using a naive formula to calculate the reading time of the post. In this instance, we're assuming that the average reading speed is 265 words per minute. We're calculating the reading time in seconds and then setting the read_time_in_seconds attribute on the post model.

Since this listener will be called when the saving model event is fired, this means that the read_time_in_seconds attribute will be calculated every time a post is created or updated before it's persisted to the database.

We can also create a listener that will soft-delete all the related posts when an author is soft-deleted.

We can create a new App\Listeners\SoftDeleteAuthorRelationships listener class:

declare(strict_types=1);

namespace App\Listeners;

use App\Events\AuthorDeleted;

final readonly class SoftDeleteAuthorRelationships
{
    public function handle(AuthorDeleted $event): void
    {
        $event->author->posts()->delete();

        // Soft delete any other relationships here...
    }
}

In the listener above, the handle method is accepting an instance of the App\Events\AuthorDeleted event class. This event class contains the author that is being deleted. We're then deleting the author's posts using the delete method on the posts relationship.

As a result, whenever an App\Models\Author model is soft-deleted, all the author's posts will also be soft-deleted.

As a side note, it's worth noting that you'd likely want to use a more robust, reusable solution for achieving this. But for the purposes of this article, we're keeping it simple.

Listening to Model Events Using Closures

Another approach you can use is to define your listeners as closures on the model itself.

Let's take our previous example of soft-deleting posts when an author is soft-deleted. We can update our App\Models\Author model to include a closure that listens for the deleted model event:

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

final class Author extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected static function booted(): void
    {
        self::deleted(static function (Author $author): void {
            $author->posts()->delete();
        });
    }

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}

We can see in the model above, that we're defining our listener inside the model's booted method. We want to listen to the deleted model event, so we've used self::deleted. Similarly, if we wanted to create a listener for the created model event, we could use self::created, and so on. The self::deleted method accepts a closure which receives the App\Models\Author that's being deleted. This closure will be executed when the model is deleted, therefore deleting all the author's posts.

I quite like this approach for very simple listeners. It keeps the logic inside the model class so it can be seen more easily by developers. Sometimes, extracting the logic out into a separate listener class can make the code harder to follow and track down, which can make it difficult to follow the flow of logic, especially if you're unfamiliar with the codebase. However, if the code inside these closures becomes more complex, it might be worth extracting the logic out into a separate listener class.

A handy tip to know is that you can also use the Illuminate\Events\queueable function to make the closure queueable. This means the listener's code will be pushed onto the queue to be run in the background rather than in the same request lifecycle. We can update our listener to be queueable like so:

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use function Illuminate\Events\queueable;

final class Author extends Model
{
    // ...

    protected static function booted(): void
    {
        self::deleted(queueable(static function (Author $author): void {
            $author->posts()->delete();
        }));
    }

    // ...
}

As we can see in our example above, we've wrapped our closure in the Illuminate\Events\queueable function.

Listening to Model Events Using Observers

Another approach you can take to listen to model events is to use model observers. Model observers allow you to define all your listeners for a model in a single class.

Typically, they are classes that exist in the app/Observers directory and they have methods that correspond to the model events you want to listen to. For example, if you want to listen to the deleted model event, you would define a deleted method in your observer class. If you wanted to listen to the created model event, you would define a created method in your observer class, and so on.

Let's take a look at how we could create a model observer for our App\Models\Author model that listens for the deleted model event:

declare(strict_types=1);

namespace App\Observers;

use App\Models\Author;

final readonly class AuthorObserver
{
    public function deleted(Author $author): void
    {
        $author->posts()->delete();
    }
}

As we can see in the code above, we've created an observer that has a deleted method. This method accepts the instance of the App\Models\Author model that is being deleted. We're then deleting the author's posts using the delete method on the posts relationship.

Let's say, as an example, we also wanted to define listeners for the created and updated model events. We could update our observer like so:

declare(strict_types=1);

namespace App\Observers;

use App\Models\Author;

final readonly class AuthorObserver
{
    public function created(Author $author): void
    {
        // Logic to run when the author is created...
    }

    public function updated(Author $author): void
    {
        // Logic to run when the author is updated...
    }

    public function deleted(Author $author): void
    {
        $author->posts()->delete();
    }
}

For the App\Observers\AuthorObserver methods to be run, we need to instruct Laravel to use it. To do this, we can make use of the #[Illuminate\Database\Eloquent\Attributes\ObservedBy] attribute. This allows us to associate the observer with the model, in a similar way to how we'd register global query scopes using the #[ScopedBy] attribute (like shown in Learn how to master Query Scopes in Laravel). We can update our App\Models\Author model to use the observer like so:

declare(strict_types=1);

namespace App\Models;

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

#[ObservedBy(AuthorObserver::class)]
final class Author extends Model
{
    // ...
}

I really like this way of defining the listener's logic because it's immediately obvious when opening a model class that it has a registered observer. So although the logic is still "hidden" in a separate file, we can be made aware that we have listeners for at least one of the model's events.

Testing Your Model Events

No matter which of the model event approaches you use, you'll likely want to write some tests to ensure your logic is being run as expected.

Let's take a look at how we might test the model events we've created in our examples above.

We'll first write a test that ensures that an author's posts are soft-deleted when the author is soft-deleted. The test may look something like so:

declare(strict_types=1);

namespace Tests\Feature\Models;

use App\Models\Author;
use App\Models\Post;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

final class AuthorTest extends TestCase
{
    use LazilyRefreshDatabase;

    #[Test]
    public function author_can_be_soft_deleted(): void
    {
        // Create our author and post.
        $author = Author::factory()->create();

        $post = Post::factory()->for($author)->create();

        // Delete the author.
        $author->delete();

        // Assert the author and their associated post
        // is soft-deleted.
        $this->assertSoftDeleted($author);
        $this->assertSoftDeleted($post);
    }
}

In the test above, we're creating a new author and a post for that author. We then soft-delete the author and assert that both the author and the post are soft-deleted.

This is a really simple, yet effective, test that we can use to ensure that our logic is working as expected. The beauty of a test like this is that it should work with each of the approaches we've discussed in this article. So if you swap between any of the approaches we've discussed, your tests should still pass.

Similarly, we can also write some tests to ensure the reading time of a post is calculated when the post is created or updated. The tests may look something like so:

declare(strict_types=1);

namespace Tests\Feature\Models;

use App\Models\Author;
use App\Models\Post;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

final class PostTest extends TestCase
{
    use LazilyRefreshDatabase;

    #[Test]
    public function read_time_is_calculated_when_storing_post(): void
    {
        $post = Post::factory()
            ->for(Author::factory())
            ->create([
                'content' => 'This is a post with some content.'
            ]);

        $this->assertSame(2, $post->read_time_in_seconds);
    }

    #[Test]
    public function read_time_is_calculated_when_updating_post(): void
    {
        $post = Post::factory()
            ->for(Author::factory())
            ->create();

        $post->content = 'This is a post with some content. ...';
        $post->save();

        $this->assertSame(8, $post->read_time_in_seconds);
    }
}

We have two tests above:

Gotchas When Using Model Events

Although model events can be really handy, there are a few gotchas to be aware of when using them.

The model events are only dispatched from Eloquent models. This means, that if you're using the Illuminate\Support\Facades\DB facade to interact with a model's underlying data in the database, its events won't be dispatched.

For instance, take this simple example where we're deleting the author using the Illuminate\Support\Facades\DB facade:

use Illuminate\Support\Facades\DB;

DB::table('authors')
    ->where('id', $author->id)
    ->delete();

Running the above code would delete the author from the database as expected. But the deleting and deleted model events wouldn't be dispatched. So if you've defined any listeners for these model events when the author is deleted, they won't be run.

Similarly, if you're mass updating or deleting models using Eloquent, the saved, updated, deleting, and deleted model events won't be dispatched for the affected models. This is because the events are dispatched from the models themselves. But when mass updating and deleting, the models aren't actually retrieved from the database, so the events aren't dispatched.

For example, say we use the following code to delete an author:

use App\Models\Author;

Author::query()->whereKey($author->id)->delete();

Since the delete method is called directly on the query builder, the deleting and deleted model events won't be dispatched for that author.

Alternative Approaches to Consider

I like using model events in my own projects. They act as a great way of decoupling my code and also allow me to automatically run logic when I don't have as much control over the code that's affecting the model. For example, if I'm deleting an author in Laravel Nova, I can still run some logic when the author is deleted.

However, it's important to know when to consider using a different approach.

To explain this point, let's take a look at a basic example of where we might want to avoid using model events. Expanding on our simple blogging application examples from earlier, let's imagine we want to run the following whenever we create a new post:

So we might create three separate listeners (one for each of these tasks) that are run every time we create a new instance of App\Models\Post.

But now let's look back at one of our tests from earlier:

declare(strict_types=1);

namespace Tests\Feature\Models;

use App\Models\Author;
use App\Models\Post;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

final class AuthorTest extends TestCase
{
    use LazilyRefreshDatabase;

    #[Test]
    public function author_can_be_soft_deleted(): void
    {
        $author = Author::factory()->create();

        $post = Post::factory()->for($author)->create();

        $author->delete();

        $this->assertSoftDeleted($author);
        $this->assertSoftDeleted($post);
    }
}

If we ran the test above, when the App\Models\Post model is created via its factory, it would also trigger those three actions. Of course, calculating the read time is a minor task so it doesn't matter too much. But we don't want to be attempting to make API calls or sending notifications during a test. These are unintended side effects. If the developer writing the tests isn't aware of these side effects, it might make it harder to track down why these actions are happening.

We also want to avoid having to write any test-specific logic in our listeners that would prevent these actions from running during a test. This would make the application code more complex and harder to maintain.

This is one of the scenarios where you might want to consider a more explicit approach rather than relying on automatic model events.

One approach could be to extract your App\Models\Post creation code up into a service or action class. For example, a simple service class may look something like so:

declare(strict_types=1);

namespace App\Services;

use App\DataTransferObjects\PostData;
use App\Models\Post;
use Illuminate\Support\Str;

final readonly class PostService
{
    public function createPost(PostData $postData): void
    {
        $post = Post::create([
            'title' => $postData->title,
            'content' => $postData->content,
            'author_id' => $postData->authorId,
            'read_time_in_seconds' => $this->calculateReadTime($postData->content),
        ]);

        $this->sendPostCreatedNotification($post);
        $this->publishToTwitter($post);
    }

    public function updatePost(Post $post, PostData $postData): void
    {
        $post->update([
            'title' => $postData->title,
            'content' => $postData->content,
            'read_time_in_seconds' => $this->calculateReadTime($postData->content),
        ]);
    }

    private function calculateReadTime(string $content): int
    {
        return (int) ceil(
            (Str::wordCount($content) / 265) * 60
        );
    }
    
    private function sendPostCreatedNotification(Post $post): void
    {
        // Send a notification to all subscribers...
    }
    
    private function publishToTwitter(Post $post): void
    {
        // Make an API call to Twitter...
    }
}

In the class above, we're manually calling the code that calculates the reading time, sends a notification, and publishes it to Twitter. This means we have more control over when these actions are run. We can also easily mock these methods in our tests to prevent them from running. We still also have the benefit of being able to queue these actions if we need to (which we likely would in this scenario).

As a result of doing this, we can remove the use of the model events and listeners for these actions. This means we can use this new App\Services\PostService class in our application code, and safely use the model factories in our test code.

A bonus of doing this is that it can also make the code easier to follow. As I've briefly mentioned, a common criticism of using events and listeners is that it can hide business logic in unexpected places. So if a new developer joins the team, they may not know where or why certain actions are happening if they're triggered by a model event.

However, if you would still like to use events and listeners for this kind of logic, you could consider using a more explicit approach. For example, you could dispatch an event from the service class that triggers the listeners. This way, you can still use the decoupling benefits of events and listeners, but you have more control over when the events are dispatched.

For example, we could update the createPost method in our App\Services\PostService example above to dispatch an event:

declare(strict_types=1);

namespace App\Services;

use App\DataTransferObjects\PostData;
use App\Events\PostCreated;
use App\Models\Post;
use Illuminate\Support\Str;

final readonly class PostService
{
    public function createPost(PostData $postData): void
    {
        $post = Post::create([
            'title' => $postData->title,
            'content' => $postData->content,
            'author_id' => $postData->authorId,
            'read_time_in_seconds' => $this->calculateReadTime($postData->content),
        ]);

        PostCreated::dispatch($post);
    }
    
    // ...
    
}

By using the approach above, we could still have separate listeners to make the API request to Twitter and send the notification. But we have more control over when these actions are run so they aren't run inside our tests when using model factories.

There aren't any golden rules when deciding to use any of these approaches. It's all about what works best for you, your team, and the feature you're building. However, I tend to follow the following rules of thumb:

Pros and Cons of Using Model Events

To quickly summarise what we've covered in this article, here's a simple list of pros and cons of using model events:

Pros

Cons

Conclusion

Hopefully, this article has given you an overview of what model events are and the different ways to use them. It should have also shown you how to test your model event code and some of the gotchas to be aware of when using them.

You should hopefully now feel confident enough to make use of model events in your Laravel apps.


The post A guide to Laravel's model events appeared first on Laravel News.

Join the Laravel Newsletter to get all the latest Laravel articles like this directly in your inbox.