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 AppEvents;
use AppModelsUser;
use IlluminateBroadcastingInteractsWithSockets;
use IlluminateFoundationEventsDispatchable;
use IlluminateQueueSerializesModels;
final class UserRegistered
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(public User $user)
{
//
}
}
In the basic example above, we have an AppEventsUserRegistered 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 AppEventsUserRegistered;
use AppModelsUser;
$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 AppEventsUserRegistered event with the user instance. Assuming the listeners are registered correctly, this will trigger any listeners that are listening for the AppEventsUserRegistered 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 AppEventsUserRegistered 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 AppListeners;
use AppEventsUserRegistered;
use AppNotificationsWelcomeNotification;
use IlluminateSupportFacadesMail;
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 AppListenersSendWelcomeEmail listener class has a handle method that accepts an AppEventsUserRegistered 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: AppModelsPost and AppModelsAuthor. We’ll say both of these models support soft deletes. When we save a new AppModelsPost, 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 AppModelsAuthor model that looks like so:
declare(strict_types=1);
namespace AppModels;
use AppEventsAuthorDeleted;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentRelationsHasMany;
use IlluminateDatabaseEloquentSoftDeletes;
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 the deleted model event to the AppEventsAuthorDeleted event class. This means when the model is deleted, a new AppEventsAuthorDeleted 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 IlluminateDatabaseEloquentSoftDeletes trait.
Now let’s create our AppModelsPost model:
declare(strict_types=1);
namespace AppModels;
use AppEventsPostSaving;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentRelationsBelongsTo;
use IlluminateDatabaseEloquentSoftDeletes;
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 AppModelsPost model above, we have:
Added a dispatchesEvents property that maps the saving model event to the AppEventsPostSaving event class. This means when the model is created or is updated, a new AppEventsPostSaving 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 IlluminateDatabaseEloquentSoftDeletes trait.
Our models are now prepared, so let’s create our AppEventsAuthorDeleted and AppEventsPostSaving event classes.
Creating the Event Classes
We will create an AppEventsPostSaving event class that will be dispatched when a new post is being saved:
declare(strict_types=1);
namespace AppEvents;
use AppModelsPost;
use IlluminateBroadcastingInteractsWithSockets;
use IlluminateFoundationEventsDispatchable;
use IlluminateQueueSerializesModels;
final class PostSaving
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(public Post $post)
{
//
}
}
In the code above, we can see the AppEventsPostSaving event class that accepts an AppModelsPost 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 AppEventsAuthorDeleted event class that will be dispatched when an author is deleted:
declare(strict_types=1);
namespace AppEvents;
use AppModelsAuthor;
use IlluminateBroadcastingInteractsWithSockets;
use IlluminateFoundationEventsDispatchable;
use IlluminateQueueSerializesModels;
final class AuthorDeleted
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(public Author $author)
{
//
}
}
In the AppEventsAuthorDeleted class above, we can see that the constructor accepts an AppModelsAuthor 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 AppListenersCalculateReadTime listener class:
declare(strict_types=1);
namespace AppListeners;
use AppEventsPostSaving;
use IlluminateSupportStr;
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 AppEventsPostSaving event is dispatched. It accepts an instance of the AppEventsPostSaving 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 AppListenersSoftDeleteAuthorRelationships listener class:
declare(strict_types=1);
namespace AppListeners;
use AppEventsAuthorDeleted;
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 AppEventsAuthorDeleted 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 AppModelsAuthor 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 AppModelsAuthor model to include a closure that listens for the deleted model event:
declare(strict_types=1);
namespace AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentRelationsHasMany;
use IlluminateDatabaseEloquentSoftDeletes;
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 AppModelsAuthor 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 IlluminateEventsqueueable 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 AppModels;
use IlluminateDatabaseEloquentModel;
use function IlluminateEventsqueueable;
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 IlluminateEventsqueueable 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 AppModelsAuthor model that listens for the deleted model event:
declare(strict_types=1);
namespace AppObservers;
use AppModelsAuthor;
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 AppModelsAuthor 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 AppObservers;
use AppModelsAuthor;
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 AppObserversAuthorObserver methods to be run, we need to instruct Laravel to use it. To do this, we can make use of the #[IlluminateDatabaseEloquentAttributesObservedBy] 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 AppModelsAuthor model to use the observer like so:
declare(strict_types=1);
namespace AppModels;
use AppObserversAuthorObserver;
use IlluminateDatabaseEloquentAttributesObservedBy;
use IlluminateDatabaseEloquentModel;
#[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 TestsFeatureModels;
use AppModelsAuthor;
use AppModelsPost;
use IlluminateFoundationTestingLazilyRefreshDatabase;
use PHPUnitFrameworkAttributesTest;
use TestsTestCase;
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 TestsFeatureModels;
use AppModelsAuthor;
use AppModelsPost;
use IlluminateFoundationTestingLazilyRefreshDatabase;
use PHPUnitFrameworkAttributesTest;
use TestsTestCase;
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 IlluminateSupportFacadesDB 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 IlluminateSupportFacadesDB facade:
use IlluminateSupportFacadesDB;
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 AppModelsAuthor;
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 AppModelsPost.
But now let’s look back at one of our tests from earlier:
declare(strict_types=1);
namespace TestsFeatureModels;
use AppModelsAuthor;
use AppModelsPost;
use IlluminateFoundationTestingLazilyRefreshDatabase;
use PHPUnitFrameworkAttributesTest;
use TestsTestCase;
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 AppModelsPost 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 AppModelsPost 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 AppServices;
use AppDataTransferObjectsPostData;
use AppModelsPost;
use IlluminateSupportStr;
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 AppServicesPostService 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 AppServicesPostService example above to dispatch an event:
declare(strict_types=1);
namespace AppServices;
use AppDataTransferObjectsPostData;
use AppEventsPostCreated;
use AppModelsPost;
use IlluminateSupportStr;
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.
Source: Read MoreÂ