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' => This email address is being protected from spambots. You need JavaScript enabled to view it. ',
]);
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:
- retrieved - retrieved from the database.
- creating - model is being created.
- created - model has been created.
- updating - model is being updated.
- updated - model has been updated.
- saving - model is being created or updated.
- saved - model has been created or updated.
- deleting - model is being deleted.
- deleted - model has been deleted.
- trashed - model has been soft deleted.
- forceDeleting - model is being force deleted.
- forceDeleted - model has been force deleted
- restoring - model is being restored from a soft delete.
- restored - model has been restored from a soft delete.
- replicating - model is being replicated.
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:
- Added a
dispatchesEvents
property that maps thedeleted
model event to theApp\Events\AuthorDeleted
event class. This means when the model is deleted, a newApp\Events\AuthorDeleted
event will be dispatched. We'll create this event class in a few moments. - Defined a
posts
relationship. - Enabled soft deletes on the model by using the
Illuminate\Database\Eloquent\SoftDeletes
trait.
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:
- Added a
dispatchesEvents
property that maps thesaving
model event to theApp\Events\PostSaving
event class. This means when the model is created or is updated, a newApp\Events\PostSaving
event will be dispatched. We'll create this event class in a few moments. - Defined an
author
relationship. - Enabled soft deletes on the model by using the
Illuminate\Database\Eloquent\SoftDeletes
trait.
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:
- The first test ensures that the reading time of a post is calculated when the post is created.
- The second test ensures that the reading time of a post is calculated when the post is updated.
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:
- Calculate the reading time of the post.
- Make an API call to X/Twitter to share the post.
- Send a notification to every subscriber on the platform.
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:
- If the action in the listener is only making minor changes to the model, consider using model events. Examples: generating slugs, calculating read times, etc.
- If the action is going to affect another model (whether that be automatically creating, updating, or deleting), then be more explicit and don't use model events.
- If the action is going to be working with external processes (API calls, file handling, triggering notifications, queued jobs), then be more explicit and don't use model events.
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
- Encourages you to decouple your code.
- Allows you to automatically trigger actions no matter where the model was created/updated/deleted. For example, you can trigger business logic if the model was created in Laravel Nova.
- You don't need to remember to dispatch the event every time you create/update/delete a model.
Cons
- Can lead to unintended side effects. You may want to create/update/delete a model without triggering some of the listeners, but this might lead to unexpected behaviour. This can be particularly problematic when writing tests.
- Can hide business logic in unexpected places that's hard to track down. This can make the flow of your code harder to follow.
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.
Read more https://laravel-news.com/model-events