Auditing User Events in Laravel
Over the years, I have been asked to track user events several different Laravel platforms I have worked on. In some cases, it was because of a simple need to check which users are logging in and using the platform, whereas in other cases there was a regulatory need to audit user actions.
There are many ways you can track user events such as adding additional code within the login controllers or storing records when certain pages are visited, however, I find that the least intrusive place to add auditing is to hook into the existing Illuminate\Auth\Events.
Auditing or Logging?
Before we dive into the detail I want to clarify the difference between auditing and logging. I will be covering the auditing of user events, which is storing the high-level detail that something happened at a given time with a particular user. In the examples below I will also be storing the IP address and browser agent string.
When you are logging user events you would generally store more information about how that event came about and perhaps even more detail about the event itself. An example might be that you track the page the user was on at the time or the referrer URL.
Audit records are normally stored for a long time, perhaps even several years in cases where there there is a legislative requirement. Audit records are there to show that "something happened". Logging on the other hand is often used to debug a situation, something to refer back to when you are trying to work out an issue. Log files may be kept for some time but are often rotated or archived after a set period.
Laravel Auth Events
The Laravel framework has several built-in events that are fired at certain points of the user auth journey. These events live in the Illuminate\Auth\Events
 namespace. Some of the more useful events for auditing user actions are:
Registered
 is fired when a new user registers.Login
 is fired whenever a user logs in to the platform.Logout
 is fired when a user explicitly logs out of the platform.PasswordReset
 is fired when a user successfully resets their password.Verified
 is fired when a user verifies their email address.
We will be writing Event Listeners and binding them to the auth events. Within the listeners, we will create records in the database that will link back to the User.
Defining what to track
Before we get started we need to decide what we are going to track. Say, for example, we are auditing events for a company that works in a regulated industry. They have a company policy that states they need to track the IP address and browser User-Agent string whenever a user logs in. A simple table structure would need to include:
A link to the User
The IP address of the incoming request
The browser User-Agent string
A timestamp of when the event occurred
In addition to the values above, to make this table more flexible in future and to allow it to track more than just login events I would also include:
The type of event
The md5 of the browser User-Agent string
Adding the md5 of the UA string will allow us to set an index on that column, which in turn will allow us to more quickly query records that share the same browser. This comes in handy if you are trying to identify patterns or identify when users log in with a browser they haven't used before.
The user_events
 table migration
To save some time we can generate the stubbed files for the UserEvent
 model and user_events
table migration with one command.
php artisan make:model UserEvent -m
In the database migration file that is created, add the following to the up()
 method:
Schema::create('user_events', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users');
$table->string('event_type')->index();
$table->string('ip_address', 45)->nullable();
$table->string('user_agent_md5', 32)->nullable()->index();
$table->text('user_agent')->nullable();
$table->timestamps();
});
Note the following:
user_id
 has been set as a Foreign Key constrained on theÂusers
 tableip_address
 is set to 45 characters to support IPv6 addressesWe've added indexes onÂ
event_type
 andÂuser_agent_md5
 to speed up lookups
To run the migration and create the table run the command:
php artisan migrate
UserEvent
 model
The only thing we need to do in the UserEvent
 model at the moment is to define the relationship to the User
 model. You can do that by adding the following method to app/Models/UserEvent.php
:
public function user()
{
return $this->belongsTo(User::class);
}
Laravel will now know that your UserEvent
 records belong to a User
 record. For more information on how Laravel deals with relationships between database tables take a look at the Eloquent: Relationships documentation.
Updating the User
 model
We also need to include the reverse relationship in the User model. Update the app/Models/User.php
file and add the method:
public function userEvents()
{
return $this->hasMany(UserEvent::class);
}
Note that this time we are using the hasMany
 relation. This is because a User can have multiple User Events over time, but an individual User Event can only "belong to" one User.
Writing an Event Listener
Now we have the database table and model preparation in place we can start building the event listeners. The event listener will take the event it is listening for and create a new User Event record in the database.
To create the listener run the following command:
php artisan make:listener AuditUserEvent
This will create a new event in the App\Listeners
 namespace. We can now update the handle()
method in app/Listeners/AuditUserEvent.php
 as follows:
public function handle($event)
{
$userEvent = new UserEvent;
$userEvent->event_type = get_class($event);
$userEvent->ip_address = request()->getClientIp();
$userEvent->user_agent_md5 = md5(request()->userAgent());
$userEvent->user_agent = request()->userAgent();
$event->user->userEvents()->save($userEvent);
}
What this code does is use the request()
 helper to access the current HTTP request and extract the client IP and browser user agent string. We create a new UserEvent
 instance and set the attribute values, then save the UserEvent
 model against the User
 which is passed in on the event.
Note: TheÂ
$event
 attribute that is passed in is not typed. This is so that we can pass in any of theÂIlluminate\Auth\Event
 classes, as long as they have a publicÂ$user
 attribute. This means you cannot use the eventsÂAttempting
 orÂLockout
 as those do not have aÂ$user
 attribute.
Binding the Event Listener
The final step is to register our event listener to receive events. This is done in the EventServiceProvider
 class. Open up the app/Providers/EventServiceProvider.php
 file and then add the following to the $listen
 array:
protected $listen = [
// ... existing bindings
\Illuminate\Auth\Events\Login::class => [
\App\Listeners\AuditUserEvent::class,
],
\Illuminate\Auth\Events\Logout::class => [
\App\Listeners\AuditUserEvent::class,
],
\Illuminate\Auth\Events\PasswordReset::class => [
\App\Listeners\AuditUserEvent::class,
],
];
In the code above we are registering our listener to the Login
, Logout
, and PasswordReset
 events, but as mentioned above you can bind the listener to any of the auth events as long as they have a public $user
 attribute.
Conclusion
At this point, you now have a flexible way of auditing user events within your Laravel application. Events are captured automatically by an event listener and records are created in the user_events
table. If all you need to do is store the events, your work here is done.
Now we are storing the user events there are other things we could build on top of what we started here. To access the audit trail for a given user you can call $user->userEvents()
 to return the list of user event records. This information could be used as simply as displaying the list on an admin screen, or you can build more interesting tools such as detecting which accounts are dormant and haven't logged in for a while. Another example might be checking which browsers the user has used in the past and triggering extra checks if you detect a new one.
Keep following the blog for more Laravel articles like this as I build out more applications and explain how I solved other challenges over the years. If you have any questions feel free to reach out.
All the best, John.