Normally when we have a Laravel app, we may need logs of actions performed by users who use our app, and surely the first thing that comes to mind is: what can be audited? how should I do it? are there external packages that do it for me? should I implement my own solution?
In this post we are going to see how to implement a third-party solution to audit user actions in a Laravel app, and for this we are going to use the laravel audit package that allows us to record user actions in a Laravel app.
As an example, we are going to do an exercise on how to audit a user’s login in a Laravel app.
Let’s start
Note: the version of the laravel audit package we will be using will be ^13.7
We install the package:
composer require owen-it/laravel-auditing
We publish the configuration files:
php artisan vendor:publish --provider "OwenIt\Auditing\AuditingServiceProvider" --tag="config"
We register the migration:
php artisan vendor:publish --provider "OwenIt\Auditing\AuditingServiceProvider" --tag="migrations"
We run the migrations:
php artisan migrate
Migrations
Let’s go to the generated migration file, and we will see a schema like the following:
/**
* Run the migrations.
*/
public function up(): void
{
$connection = config('audit.drivers.database.connection', config('database.default'));
$table = config('audit.drivers.database.table', 'audits');
Schema::connection($connection)->create($table, function (Blueprint $table) {
$morphPrefix = config('audit.user.morph_prefix', 'user');
$table->bigIncrements('id');
$table->string($morphPrefix . '_type')->nullable();
$table->unsignedBigInteger($morphPrefix . '_id')->nullable();
$table->string('event');
$table->morphs('auditable');
$table->text('old_values')->nullable();
$table->text('new_values')->nullable();
$table->text('url')->nullable();
$table->ipAddress('ip_address')->nullable();
$table->string('user_agent', 1023)->nullable();
$table->string('tags')->nullable();
$table->timestamps();
$table->index([$morphPrefix . '_id', $morphPrefix . '_type']);
});
}
We see that a table called audits is created with the following fields:
- id: the record id.
- user_type: the user type.
- user_id: the user id.
- event: the event that was performed.
- auditable_type: the auditable type.
- auditable_id: the auditable id.
- old_values: the previous values.
- new_values: the new values.
- ip_address: the user’s ip.
- user_agent: the user’s user agent.
- tags: the tags.
- created_at: the creation date and time.
- updated_at: the update date and time.
We have to keep in mind that we can add the fields we need to the audits table
Configuration
In the migration we see that we have a morphable
$table->string($morphPrefix . '_type')->nullable();
$table->unsignedBigInteger($morphPrefix . '_id')->nullable();
By default, this morphable is the User model, but we can change it in the configuration file that is in config/audit.php as follows:
'user' => [
'morph_prefix' => 'user',
],
],
if you change this prefix, when you run the migration, the morphable will be the model you have assigned, and instead of showing you user_type and user_id, in your database manager,
it will show you the name of the model you have assigned.
Model
To audit a user’s actions, we have to add a trait provided by the package, and the model must extend to a contract that is also provided by the package.
uuse OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use OwenIt\Auditing\Auditable as AuditableTrait;
class User extends Authenticatable implements AuditableContract
{
use AuditableTrait;
}
Audit actions
When we add the trait and the contract, when we create a new user, a new record will be created in the audits table with the created event.
If we edit it, a new record will be created with the updated event.
By default, the package audits creation, editing, deletion, and restoration actions, but we can adjust it to our needs.
In this post, we are going to audit a user’s login, which this action does not represent a create, update or delete, so we are going to have to create a custom action.
note: custom actions are only available from version v13
To do this, we have to deactivate (in case you don’t want to audit the create, update or delete) and within the User model, we are going to add the following code:
/**
* Attributes to exclude from the Audit.
*
* @var array
*/
protected $auditEvents = [];
When we leave $auditEvents empty, we prevent the package from auditing the default actions
Audit configuration with sanctum
I am using sanctum for login, so we need a small change in the configuration of config/auth.php
We have to tell laravel audit that we have authentication with sanctum as follows:
'user' => [
...
'guards' => [
...
'sanctum'
],
],
Audit login
I’m sure at this point, you already have a login in your app, so we need to add a small code to the controller we use for login.
In my case I use the LoginController and it looks something like this
<?php
namespace App\Http\Controllers\API\Auth;
use App\Http\Controllers\Controller;
use App\Http\Responses\{LoginResponse};
use App\Models\User;
use Illuminate\Http\Request;
class LoginController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function login(Request $request)
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
'device_name' => ['required'],
]);
$user = User::where('email', $request->email)->first();
...bussiness login
return new LoginResponse($user);
}
...
}
Inside the controller, we will add the code:
We import
use Illuminate\Support\Facades\Event;
use OwenIt\Auditing\Events\AuditCustom;
We create the auditLogin method
public function auditLogin(User $user): void
{
$user->auditEvent = 'login';
$user->isCustomEvent = true;
$user->auditCustomOld = [
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'email' => $user->email,
'is_active' => $user->is_active,
'phone' => $user->phone,
... more fields
];
$user->auditCustomNew = [];
Event::dispatch(new AuditCustom($user));
}
We incorporate the auditLogin method in the login method
...
$this->auditLogin($user);
...
Now when a user logs in, it will create a new record in the audits table with the login event.
Let’s test it with a small test:
#[Test]
public function should_save_the_audit_when_user_do_login()
{
$user = User::factory()->create();
$response = $this->postJson(route('api.v1.login'), [
'email' => $user->email,
'password' => 'password',
'device_name' => 'iPhone of ' . $user->name
]);
$response->assertOk();
$this->assertDatabaseCount(Audit::class, 1);
$audit = Audit::first();
$this->assertDatabaseHas(Audit::class, [
'auditable_id' => $user->id,
'auditable_type' => User::class,
'event' => 'login',
]);
$this->assertEquals($audit->old_values, [
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'email' => $user->email,
'is_active' => true,
'phone' => $user->phone,
]);
$this->assertEquals($audit->new_values, []);
}
Result
When a user logs in, a new record will be created in the audits table with the login event.
[
"id" => 1
"user_type" => null
"user_id" => null
"event" => "login"
"auditable_type" => "App\Models\User"
"auditable_id" => "9e505f77-1ca5-4d04-b815-b060c48fd9fb"
"old_values" => array:5 [
"first_name" => "Luna"
"last_name" => "Gibson"
"email" => "hannah27@example.org"
"is_active" => true
"phone" => "1-541-373-6229"
]
"new_values" => []
"url" => "console"
"ip_address" => "127.0.0.1"
"user_agent" => "Symfony"
"tags" => null
"created_at" => "2025-02-27T18:19:22.000000Z"
"updated_at" => "2025-02-27T18:19:22.000000Z"
]
And ready, now we can know when a user logged in.
It is worth noting that you can audit any action of any model, not just for users, the login has just been an example exercise.
In conclusion:
Resolving the doubts we had at the beginning, at this point we could already answer them:
- what can be audited?
- any action that is performed in the app, to any model.
- how is it audited?
- Auditing is a historical record of the actions that are performed on the models, so a record is created for each action.
- are there external packages that do it for me?
- yes, we can use the laravel audit package.
- should I implement my own solution?
- You could do it from scratch, but the most recommended is to use a package that has already been tested by the community.