DEV Community

Cover image for Building an Audit Log in Laravel with spatie/laravel-activitylog v5
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Building an Audit Log in Laravel with spatie/laravel-activitylog v5

Originally published at hafiz.dev


Every SaaS reaches a point where "who changed that?" stops being a casual question and starts being a support ticket. A team member deletes a project. A setting gets changed and nobody knows when. A user loses access and blames an admin. Without an audit log, you're guessing. And in enterprise deals, the absence of audit logging can be an actual blocker.

spatie/laravel-activitylog has been the go-to solution for this in the Laravel ecosystem for years, with over 48 million Packagist installs. Version 5 shipped in late March 2026, and it's a meaningful upgrade: PHP 8.4+, a cleaner API, a new database schema, and properly swappable internals. This post walks through building a complete audit log system for a Laravel SaaS using v5, from installation to displaying the log in Filament.

If you're already on v4, there's a migration section at the end covering the breaking changes.

What Changed in v5

Freek covered the full list on his blog, but the things that matter most for day-to-day use:

No boilerplate for basic model logging. In v4, adding the LogsActivity trait to a model also required a getActivitylogOptions() method even for the simplest cases. In v5, the trait alone is enough to start logging. You only override getActivitylogOptions() when you need custom behaviour.

New attribute_changes column. The old changes column is replaced by attribute_changes, which stores a cleaner structure with attributes (the new values) and old (the previous values). This means a small schema migration if you're upgrading, but fresh installs get a better foundation.

ActivityEvent enum for type-safe filtering. v5 introduces an ActivityEvent enum so you're not relying on raw strings when filtering by event type:

use Spatie\Activitylog\Enums\ActivityEvent;

Activity::forEvent(ActivityEvent::Created)->get();
Activity::forEvent(ActivityEvent::Updated)->get();
Activity::forEvent(ActivityEvent::Deleted)->get();
Enter fullscreen mode Exit fullscreen mode

Plain strings still work for custom event names. But for the standard events, the enum gives you autocompletion and catches typos at the IDE level rather than at runtime.

Customizable action classes. The core operations (saving activities, cleaning old records) are now action classes you can extend and swap via config. This makes it practical to do things like queue activity saves during a request, or redact sensitive fields before anything hits the database.

Installation

Requires PHP 8.4+ and Laravel 12 or 13. Install the package:

composer require spatie/laravel-activitylog
Enter fullscreen mode Exit fullscreen mode

Publish and run the migrations:

php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations"
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

This creates the activity_log table with the new v5 schema. You can find the full list of available artisan commands in the Laravel Artisan Commands reference.

Optionally publish the config file:

php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-config"
Enter fullscreen mode Exit fullscreen mode

The config file at config/activitylog.php controls the activity model class, the default log name, the number of days before old records get pruned, and the action classes used internally.

Manual Activity Logging

The simplest usage is logging arbitrary events:

activity()->log('User exported the reports CSV');
Enter fullscreen mode Exit fullscreen mode

More useful is attaching context. You want to know what was affected and who did it:

activity()
    ->performedOn($project)
    ->causedBy($user)
    ->withProperties(['plan' => 'pro', 'via' => 'settings-page'])
    ->log('upgraded plan');
Enter fullscreen mode Exit fullscreen mode

Retrieving logged activities uses the Activity model with a set of built-in query scopes:

use Spatie\Activitylog\Models\Activity;

// All activity for a specific subject
Activity::forSubject($project)->get();

// All activity caused by a user
Activity::causedBy($user)->get();

// Filter by event type
Activity::forEvent('updated')->get();

// Filter by log name (useful when grouping logs by domain)
Activity::inLog('billing')->get();

// Combine scopes
Activity::forSubject($project)
    ->causedBy($user)
    ->latest()
    ->get();
Enter fullscreen mode Exit fullscreen mode

Each Activity record gives you description, subject, causer, event, properties, and attribute_changes. The getProperty() helper reads from the custom properties you attached.

Automatic Model Event Logging

This is where the package earns its place. Add the LogsActivity trait to any Eloquent model and it automatically logs created, updated, and deleted events:

use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Models\Concerns\LogsActivity;

class Project extends Model
{
    use LogsActivity;
}
Enter fullscreen mode Exit fullscreen mode

That's all you need for basic logging. Any create, update, or delete on this model now creates an activity record.

To control which attributes get tracked, override getActivitylogOptions():

use Spatie\Activitylog\Support\LogOptions;

class Project extends Model
{
    use LogsActivity;

    protected $fillable = ['name', 'description', 'status', 'owner_id'];

    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->logOnly(['name', 'status', 'owner_id'])
            ->logOnlyDirty()
            ->dontSubmitEmptyLogs();
    }
}
Enter fullscreen mode Exit fullscreen mode

logOnly() limits tracking to specific attributes. logOnlyDirty() means only attributes that actually changed get recorded, not everything including updated_at noise. dontSubmitEmptyLogs() skips saving a record when nothing meaningful changed.

When a project gets updated, the activity record's attribute_changes looks like this:

$activity->attribute_changes;
// [
//     'attributes' => [
//         'status' => 'active',
//         'owner_id' => 42,
//     ],
//     'old' => [
//         'status' => 'draft',
//         'owner_id' => 7,
//     ],
// ]
Enter fullscreen mode Exit fullscreen mode

You can also use logAll() combined with logExcept() to track everything except specific fields:

return LogOptions::defaults()
    ->logAll()
    ->logExcept(['remember_token', 'updated_at']);
Enter fullscreen mode Exit fullscreen mode

And logFillable() to automatically track whatever is in the $fillable array, useful when your fillable list is the authoritative record of what users can change:

return LogOptions::defaults()
    ->logFillable()
    ->logOnlyDirty();
Enter fullscreen mode Exit fullscreen mode

In a typical SaaS you'd apply this to several models at once. A project management app might log changes to Project, Team, Invitation, and Role models, each tracking different attributes:

class Team extends Model
{
    use LogsActivity;

    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->logOnly(['name', 'owner_id', 'plan'])
            ->logOnlyDirty()
            ->setDescriptionForEvent(fn(string $event) => "Team was {$event}")
            ->dontSubmitEmptyLogs();
    }
}

class Invitation extends Model
{
    use LogsActivity;

    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->logOnly(['email', 'role', 'accepted_at'])
            ->logOnlyDirty()
            ->useLogName('invitations');
    }
}
Enter fullscreen mode Exit fullscreen mode

The setDescriptionForEvent() method lets you control the human-readable description that gets stored. The default is just the event name ("updated", "created"), but a more descriptive string is easier to read in an admin panel.

Grouping Activity with Named Logs

By default everything lands in the default log. For a SaaS with distinct domains (billing, security, content), separating into named logs keeps queries focused and makes it practical to surface the right activity in the right UI context.

Set a log name on the model:

class SubscriptionChange extends Model
{
    use LogsActivity;

    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->useLogName('billing')
            ->logOnly(['plan', 'status', 'cancelled_at']);
    }
}
Enter fullscreen mode Exit fullscreen mode

Or set it on a manual log call:

activity('security')
    ->causedBy($user)
    ->withProperties(['ip' => request()->ip()])
    ->log('Two-factor authentication disabled');
Enter fullscreen mode Exit fullscreen mode

Then query each log independently:

// Billing events only
Activity::inLog('billing')->latest()->get();

// Security events for a specific user
Activity::inLog('security')
    ->causedBy($user)
    ->latest()
    ->get();
Enter fullscreen mode Exit fullscreen mode

In Filament, add a filter that lets admins switch between log channels:

Tables\Filters\SelectFilter::make('log_name')
    ->label('Log')
    ->options([
        'default'  => 'General',
        'billing'  => 'Billing',
        'security' => 'Security',
    ]),
Enter fullscreen mode Exit fullscreen mode

This pattern avoids the query performance issues that come from filtering a single massive activity_log table by subject type. Named logs give you logical partitioning without needing separate database tables.

Enriching Logs Before They Save

Sometimes you need to attach extra context right before an activity is persisted. The beforeActivityLogged() method on your model runs at that moment:

class Project extends Model
{
    use LogsActivity;

    public function beforeActivityLogged(Activity $activity, string $eventName): void
    {
        $activity->properties = $activity->properties->merge([
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the right place to add request context, session data, or anything that isn't on the model itself. Don't use model observers for this. The beforeActivityLogged hook runs in the correct position in the activity lifecycle.

Redacting Sensitive Fields

By default, if you log a User model, attribute changes will include whatever fields you track. If that includes anything sensitive, you want to strip it before it hits the database.

Create a custom action class:

namespace App\ActivityLog;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Spatie\Activitylog\Actions\LogActivityAction;

class RedactSensitiveFieldsAction extends LogActivityAction
{
    protected function transformChanges(Model $activity): void
    {
        $changes = $activity->attribute_changes?->toArray() ?? [];

        Arr::forget($changes, [
            'attributes.password',
            'old.password',
            'attributes.two_factor_secret',
            'old.two_factor_secret',
        ]);

        $activity->attribute_changes = $changes;
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it in config/activitylog.php:

'actions' => [
    'log_activity' => \App\ActivityLog\RedactSensitiveFieldsAction::class,
],
Enter fullscreen mode Exit fullscreen mode

Now password changes never appear in your logs, regardless of which model triggers them. You can also override save() on the action class to dispatch a queued job instead of writing synchronously, which helps if you're concerned about activity logging adding latency during high-traffic requests. The queue jobs guide covers the patterns that apply here.

Displaying the Activity Log in Filament

An audit log is only useful if someone can actually read it. If you're using Filament, the quickest way is a dedicated resource. If you're building a SaaS admin panel, the Filament admin guide covers the broader setup.

Generate the resource:

php artisan make:filament-resource ActivityLog --view
Enter fullscreen mode Exit fullscreen mode

Then configure the list table in ActivityLogResource.php:

use Spatie\Activitylog\Models\Activity;

public static function getModel(): string
{
    return Activity::class;
}

public static function table(Table $table): Table
{
    return $table
        ->columns([
            Tables\Columns\TextColumn::make('causer.name')
                ->label('User')
                ->searchable()
                ->sortable(),

            Tables\Columns\TextColumn::make('description')
                ->label('Action')
                ->searchable(),

            Tables\Columns\TextColumn::make('subject_type')
                ->label('Subject')
                ->formatStateUsing(fn ($state) => class_basename($state))
                ->sortable(),

            Tables\Columns\TextColumn::make('event')
                ->badge()
                ->color(fn (string $state): string => match ($state) {
                    'created' => 'success',
                    'updated' => 'warning',
                    'deleted' => 'danger',
                    default   => 'gray',
                }),

            Tables\Columns\TextColumn::make('created_at')
                ->label('When')
                ->dateTime()
                ->sortable(),
        ])
        ->defaultSort('created_at', 'desc')
        ->filters([
            Tables\Filters\SelectFilter::make('event')
                ->options([
                    'created' => 'Created',
                    'updated' => 'Updated',
                    'deleted' => 'Deleted',
                ]),
        ])
        ->actions([
            Tables\Actions\ViewAction::make(),
        ]);
}
Enter fullscreen mode Exit fullscreen mode

For the view page, show the attribute_changes as a formatted diff so admins can see exactly what changed:

public static function infolist(Infolist $infolist): Infolist
{
    return $infolist
        ->schema([
            Infolists\Components\TextEntry::make('causer.name')->label('User'),
            Infolists\Components\TextEntry::make('description')->label('Action'),
            Infolists\Components\TextEntry::make('created_at')->label('When')->dateTime(),
            Infolists\Components\KeyValueEntry::make('attribute_changes.attributes')
                ->label('New values'),
            Infolists\Components\KeyValueEntry::make('attribute_changes.old')
                ->label('Previous values'),
        ]);
}
Enter fullscreen mode Exit fullscreen mode

This gives admins a readable before/after comparison for any update event. For larger teams, you'd add subject-specific filters and restrict access to senior roles via Filament's policy integration, which is covered in the full SaaS guide.

Cleaning Up Old Records

Activity logs grow fast. The package ships a built-in command that removes records older than the number of days set in config:

php artisan activitylog:clean
Enter fullscreen mode Exit fullscreen mode

Set the retention period in config/activitylog.php:

'delete_records_older_than_days' => 90,
Enter fullscreen mode Exit fullscreen mode

Schedule it in routes/console.php:

Schedule::command('activitylog:clean')->daily();
Enter fullscreen mode Exit fullscreen mode

90 days is a reasonable default for most SaaS products. If you're in a regulated industry (healthcare, finance), you'll want to check your compliance requirements. Some industries mandate 12+ months of audit history.

Migrating from v4

If you're upgrading an existing project, the breaking changes require attention. These are the ones that will actually affect your code:

PHP and Laravel version requirements. v5 requires PHP 8.4+ and Laravel 12+. If you're on older versions, stay on v4 until you've upgraded.

New database column. v5 introduces an attribute_changes column that replaces the old changes column. Create a migration:

Schema::table('activity_log', function (Blueprint $table) {
    $table->json('attribute_changes')->nullable()->after('properties');
});
Enter fullscreen mode Exit fullscreen mode

You'll need to decide what to do with existing changes data. For most teams, archiving the old column and letting new records use attribute_changes is simpler than trying to migrate the data format.

Relation renames. Two relations changed names:

// v4
$user->activity;         // relation to activities caused by this user
$model->activity;        // relation to activities on this model

// v5
$user->actions;          // renamed
$model->activities;      // renamed
Enter fullscreen mode Exit fullscreen mode

Search your codebase for ->activity and update accordingly.

Accessing changes. The changes() method became a property:

// v4
$activity->changes();

// v5
$activity->changes; // or $activity->attribute_changes
Enter fullscreen mode Exit fullscreen mode

Removed config options. table_name and database_connection were removed from the config file. If you need a custom table or connection, create a custom Activity model with $table and $connection properties, then point activity_model in config to that class.

Removed method: addLogChange(), LoggablePipe, and EventLogBag are gone. If you used these to manipulate the changes array, override transformChanges() on a custom LogActivityAction instead; the pattern is shown in the redacting section above.

Before upgrading, check your composer.json for any secondary packages that depend on spatie/laravel-activitylog. This is good practice any time you're doing major version bumps. The auditing your Composer dependencies post covers the workflow.

Testing Your Activity Log

The package ships with a withoutLogs() helper that's useful in tests where you don't want activity logging to interfere:


it('updates a project', function () {
    $project = Project::factory()->create();

    // Disable logging just for this test
    activity()->disableLogging();
    $project->update(['name' => 'Updated Name']);
    activity()->enableLogging();

    expect($project->fresh()->name)->toBe('Updated Name');
    expect(Activity::count())->toBe(0);
});
Enter fullscreen mode Exit fullscreen mode

When you actually want to assert that activity was logged correctly, test it explicitly:

it('logs when a project status changes', function () {
    $user = User::factory()->create();
    $project = Project::factory()->create(['status' => 'draft']);

    actingAs($user);
    $project->update(['status' => 'active']);

    $activity = Activity::latest()->first();

    expect($activity->causer->id)->toBe($user->id)
        ->and($activity->event)->toBe('updated')
        ->and($activity->attribute_changes['attributes']['status'])->toBe('active')
        ->and($activity->attribute_changes['old']['status'])->toBe('draft');
});
Enter fullscreen mode Exit fullscreen mode

Testing the beforeActivityLogged hook works the same way: update the model and assert the custom property was merged onto the activity record. The hook runs synchronously during the model save, so there's no async complexity to deal with in tests.

One thing worth knowing: if you dispatch activity logging via a queued job (using the custom action class pattern), use Queue::fake() in tests and assert the job was dispatched rather than asserting the activity was saved directly.

FAQ

Does v5 work with Laravel 12 and 13?

Yes. The package requires illuminate/support: ^12.0 || ^13.0, so both are supported. Laravel 11 and older are not supported in v5. Stay on v4 if you haven't upgraded yet.

Can I log activity without a logged-in user?

Yes. When there's no authenticated user, causer is null and the log still saves. This is useful for logging background jobs or system-triggered events. You can also set a causer explicitly with causedBy($model).

How do I log soft-deleted models?

The LogsActivity trait hooks into Eloquent model events including SoftDeleting. As long as your model uses SoftDeletes, the activity log records deleted events automatically. Restores are also captured.

Can I use multiple log channels?

Yes. Use useLogName() in getActivitylogOptions() to route activity to different named logs. Then query with Activity::inLog('billing') or Activity::inLog('security'). Useful when you want separate audit trails for different parts of your app.

How do I prevent logging during imports or seeders?

Call activity()->disableLogging() before the operation and activity()->enableLogging() after. This works in tests, seeders, and bulk import scripts anywhere you need a clean run without filling the activity log with noise.

Build It Once, Thank Yourself Later

Audit logs feel optional until the moment they aren't. A team member makes an unexpected change, a user disputes account history, a compliance requirement surfaces. By then it's too late to add the log retroactively.

The good news is that v5 makes the setup surprisingly lightweight. Add the trait, configure what to track, schedule the cleanup command. That's the core of it. Filament display, sensitive field redaction, and queued saves are all additions you can layer in as your needs grow.

If you're setting up activity logging on a production app and want a second set of eyes on the implementation, get in touch.

Top comments (0)