spatie/laravel-activitylog
Easily log user activities in your Laravel app and automatically track model events. Store everything in the activity_log table, then retrieve and query activities with the Activity model and a fluent API.
Install (5 minutes)
composer require spatie/laravel-activitylog
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations"
php artisan migrate
# optional but almost always worth it
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-config"
Version gate: v5.x requires PHP 8.4+ and Laravel 11+. If you are on Laravel 10 / PHP 8.2, pin to the 4.x line (^4.12) — the config and traits are broadly compatible but the internals were modernized in v5, so read UPGRADING.md before bumping.
Before migrating, check your ID strategy. The published migration uses nullableMorphs('subject') and nullableMorphs('causer') with unsigned big integers. If your models use UUIDs/ULIDs, edit the migration before running it to nullableUuidMorphs() (or foreignUlid-style columns) — retrofitting later means a data migration.
Where to look first, in this order:
config/activitylog.php — the whole behavioural surface is here (enabled, default_log_name, default_auth_driver, subject_returns_soft_deleted_models, activity_model, table_name, database_connection).Spatie\Activitylog\LogOptions — the fluent object returned by getActivitylogOptions(); this is where 90% of your day-to-day decisions live.Spatie\Activitylog\Models\Activity — what you query, what you render.spatie.be/docs/laravel-activitylog — specifically Advanced usage → Logging model events, Using multiple logs, and Batch logs.First use case: an audit trail on one model.
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
class Invoice extends Model
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status', 'total', 'paid_at'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->useLogName('invoice');
}
}
$invoice->update(['status' => 'paid']);
$activity = Activity::latest()->first();
$activity->description; // "updated"
$activity->subject; // Invoice instance
$activity->attribute_changes; // ['attributes' => ['status' => 'paid'], 'old' => ['status' => 'draft']]
That is the whole package in miniature: declare intent on the model, read rows back through the Activity model.
logOnly + logOnlyDirty, not logAlllogAll() on a large table writes every column, every save, into a JSON column — including blobs, timestamps and noise that makes the UI unreadable. The pattern that scales:
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly($this->auditableAttributes()) // explicit whitelist
->logOnlyDirty()
->dontLogEmptyChanges() // skip no-op saves
->dontSubmitEmptyLogs() // skip create/delete with nothing to say
->useLogName(LogName::Billing); // BackedEnum supported since 5.1
}
dontLogEmptyChanges() is the one people forget: without it, $model->save() on an unchanged model still writes a row.
The default description is the event name (created, updated, deleted). Override it per model so your timeline reads well without post-processing:
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status'])
->setDescriptionForEvent(fn (string $eventName) => "invoice {$eventName}");
}
For one-off events, set it at the call site with ->event('approved') and ->log('Invoice approved by finance').
tapActivity()This is the cleanest extension point for tenant, IP, user agent, request ID:
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
{
$activity->properties = $activity->properties->merge([
'tenant_id' => tenant()->id,
'ip' => request()->ip(),
]);
}
Keep it cheap — this runs inside the model's save lifecycle. Note tapActivity is invoked once per logged activity (a double-invocation bug was fixed in 4.11; if you are on an older 4.x you may be seeing duplicated properties).
Not everything is an Eloquent save. Logins, exports, permission grants, API calls:
activity()
->performedOn($report) // subject; also accepts a collection
->causedBy($user) // defaults to auth()->user()
->event('downloaded') // goes into the `event` column
->withProperties(['format' => 'csv', 'rows' => 1200])
->log('Report downloaded');
Put these in a small domain service or an action class, not scattered through controllers — otherwise the log names and descriptions drift and you cannot query them reliably.
Add CausesActivity to your User and HasActivity to anything that should expose its history:
$user->actions; // everything this user did (causer)
$invoice->activities; // everything that happened to this invoice (subject)
Then a "user profile → recent activity" panel is three lines. Eager-load ->with(['subject', 'causer']) when rendering lists, or you will get N+1 per row.
Use log_name to partition the timeline (auth, billing, admin, security) and query per-context:
Activity::where('log_name', 'billing')->latest()->paginate(50);
This is cheaper than separate tables and lets the admin UI cross-cut when needed. Keep the set of log names in an enum so typos don't silently create new partitions.
When one user action produces several activity rows, group them so the UI can collapse them:
activity()->withinBatch($batchUuid)->performedOn($invoice)->log('...');
// or wrap several logs
The batch UUID is stored on each row; query Activity::where('batch_uuid', $uuid). (The withinBatch callback now closes the batch in a finally, so exceptions no longer leave it open.)
The package ships a cleanup command. Schedule it from day one:
// routes/console.php or Kernel
Schedule::command('activitylog:clean', ['--days' => 365])->daily();
The --days argument must be an integer (enforced since 4.12.1). Decide the retention window with the business before the table has 40 million rows.
Build a thin presenter or query object rather than pushing Activity into Blade:
Activity::query()
->with(['subject', 'causer'])
->whereIn('log_name', $allowedLogNames)
->where('created_at', '>=', $since)
->latest()
->paginate(50);
Restrict log_name by the viewing user's role — an activity log is a data-leak surface and the package does no authorization for you.
config(['activitylog.enabled' => true]); // or false, to isolate unrelated tests
$invoice->update(['status' => 'paid']);
$this->assertDatabaseHas('activity_log', [
'subject_type' => Invoice::class,
'subject_id' => $invoice->id,
'event' => 'updated',
'log_name' => 'billing',
]);
Set enabled => false in phpunit.xml globally and opt in per test, or your suite will spend measurable time writing audit rows you never assert on.
Empty logs look like bugs. With dontSubmitEmptyLogs(), saving a model where no whitelisted attribute changed produces no row. People report "logging stopped working" when the real cause is logOnly not covering the column they changed. Debug by temporarily removing logOnlyDirty() and dumping attribute_changes.
logOnlyDirty() compares the model's dirty state, not the DB. Updates issued through the query builder (Invoice::where(...)->update([...])) bypass Eloquent events entirely and are never logged. If finance runs bulk operations, they need explicit activity() calls or a DB-level audit path. Same for Model::insert() and raw SQL.
Causer resolution fails silently outside HTTP. Console commands, queued jobs, and scheduled tasks have no authenticated user, so causer_id is null. If your job has an originating user, pass it explicitly: ->causedBy($this->actingUser). If you use multiple guards, set default_auth_driver or always pass causedBy() — the package uses one resolver and will otherwise pick the wrong (or no) user.
Queued listeners and closures can break serialization. Storing models in LogOptions closures can prevent queued listeners from serializing (fixed in 4.12.3). Keep tapActivity and setDescriptionForEvent closures free of captured model instances where possible.
Soft deletes make subject null. With subject_returns_soft_deleted_models => true you get the trashed model back; otherwise, ->subject is null for trashed rows. Always null-guard in Blade: {{ $activity->subject?->name ?? '(deleted)' }}. Related: subject() used to throw for non-SoftDeletes models (fixed in 4.11) — pin ^4.11 or ^5 if you hit that.
Morph maps and refactors. subject_type stores the fully-qualified class name unless you configure a morph map. Rename or move a model and every historical row points at a class that no longer exists, and $activity->subject returns null. Either adopt Relation::enforceMorphMap() early or plan a data migration on every model rename.
Don't call Activity::all(). It's the README demo, not a pattern — it loads the entire table into memory. Use latest()->paginate(). Likewise Activity::all()->last() in tests becomes progressively slower as the suite grows; use Activity::latest()->first().
Index the table yourself. The default migration indexes morph columns but not your query patterns. Add composite indexes matching how you actually read — typically (log_name, created_at), (subject_type, subject_id, created_at), (causer_type, causer_id, created_at).
Transactions and rollbacks. Activity rows are written in the same code path as the model save, so a rolled-back transaction generally rolls the activity back with it — but not if you log inside an afterCommit context or from a separate connection (database_connection config). Pick one and be consistent, or your audit trail will claim things happened that didn't.
Property payloads grow. properties is a JSON column; attribute_changes for a logAll() model can hold the entire row, twice (old + new), per save. On a table with a text column you will quietly write megabytes. Audit SELECT MAX(LENGTH(properties)) after a week in staging.
Disabling in a hurry. activity()->withoutLogs(fn () => ...) (and the model-level enable/disable helpers) are useful for imports, seeders and migrations that would otherwise generate tens of thousands of rows. Wrap bulk import loops in withoutLogs deliberately and log a single summary row instead.
Config caching. activitylog.enabled, table_name and activity_model are read from config. After changing them, php artisan config:clear — a cached config in production will appear to ignore your edit.
Extension points, ranked by how often you'll want them:
tapActivity() — per-model enrichment. Start here.setDescriptionForEvent() / ->event() — presentation. Start here too.config('activitylog.activity_model') — a custom Activity subclass when you need extra accessors, casts or default scopes. It must implement Spatie\Activitylog\Contracts\Activity.config('activitylog.table_name') — env-driven table naming, handy for multi-tenant-per-schema setups.activity() in your own action class — when log names, descriptions and properties need to be enforced rather than remembered.Multi-tenancy. There is no built-in tenant column. The standard approach is tapActivity() writing tenant_id into properties — but that means it is a JSON column, not an indexed one. If tenant isolation is a hard requirement (and for audit logs it usually is), add a real tenant_id column to the migration and a global scope on your custom Activity model instead. Decide this before the first release; it is painful to retrofit.
How can I help you explore Laravel packages today?