From Laravel toolkit
Use when adding an audit trail, recording who did what, logging login or model events, or tracking changes with spatie/laravel-activitylog v5 — keywords spatie activitylog, activity log, LogsActivity trait, causedBy, performedOn, withProperties, activity_log table, getActivitylogOptions, logOnlyDirty, ActivityEvent, beforeLogging, custom column grouping, organisation scoping.
How this skill is triggered — by the user, by Claude, or both
Slash command
/laravel:laravel-activity-loggingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Audit trails with `spatie/laravel-activitylog` v5. Records who did what to which model, with what changed.
Audit trails with spatie/laravel-activitylog v5. Records who did what to which model, with what changed.
Related guides:
ActivityEvent is a backed enumThere are two ways to log:
LogsActivity trait to a model and it logs created / updated / deleted / restored for you, recording attribute changes.activity() helper for domain events that aren't a single model write (logins, exports, bulk actions, external side effects).Both write to the activity_log table, queryable via the Spatie\Activitylog\Models\Activity model. Setup (config, migration) is already done — this guide is usage only.
Use automatic model logging when:
Patient, what fields changed)Use manual logging when:
| Operation | Code |
|---|---|
| Log a model automatically | use LogsActivity; + getActivitylogOptions() |
| Log only changed attributes | LogOptions::defaults()->logOnly([...])->logOnlyDirty() |
| Exclude sensitive fields | ->logExcept(['password', 'remember_token']) |
| Suppress no-op updates | ->dontLogEmptyChanges() |
| Manual log | activity()->causedBy($user)->performedOn($subject)->log('...') |
| Add custom data | ->withProperties(['ip' => $ip]) (many) / ->withProperty('ip', $ip) (one) |
| Tag the event type | ->event('verified') |
| Write to a named log | activity('auth')->log('...') |
| Read what changed | $activity->attribute_changes (['attributes'=>..., 'old'=>...]) |
| Query by causer | Activity::causedBy($user)->get() |
| Query a subject's history | Activity::forSubject($patient)->get() |
| Disable for a block | activity()->withoutLogging(fn () => ...) |
| Set causer in a job | Activity::defaultCauser($admin, fn () => ...) |
| Prune old records | php artisan activitylog:clean --force |
Add the trait and implement getActivitylogOptions(). In v5 the trait lives under Models\Concerns (see Common Mistakes).
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
class Patient extends Model
{
use LogsActivity;
use SoftDeletes;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['first_name', 'last_name', 'email', 'status'])
->logExcept(['remember_token'])
->logOnlyDirty() // only record attributes that actually changed
->dontLogEmptyChanges() // skip the activity entirely if nothing changed
->useLogName('patient')
->setDescriptionForEvent(fn (string $eventName): string => "Patient {$eventName}");
}
}
This logs created, updated, deleted, and — because the model uses SoftDeletes — restored automatically. Without SoftDeletes you get exactly created / updated / deleted. To pin the set explicitly regardless, use $recordEvents (below).
Other attribute selectors (pick one as the base, then refine):
LogOptions::defaults()->logAll(); // every attribute
LogOptions::defaults()->logFillable(); // all $fillable attributes
LogOptions::defaults()->logUnguarded(); // all attributes not in $guarded
LogOptions::defaults()->logOnly(['name', 'status', 'parent.name']); // dot-notation walks relations
Restrict which events fire with static props on the model:
protected static array $recordEvents = ['updated', 'deleted']; // only these
// or
protected static array $doNotRecordEvents = ['created']; // all except these
Skip an activity when only noise changed:
LogOptions::defaults()
->logOnlyDirty()
->dontLogIfAttributesChangedOnly(['last_seen_at', 'updated_at']);
Mutate the activity before it's saved with the beforeActivityLogged hook on the model:
use Spatie\Activitylog\Contracts\Activity;
public function beforeActivityLogged(Activity $activity, string $eventName): void
{
$activity->properties = $activity->properties->put('ip', request()->ip());
}
use Spatie\Activitylog\Models\Activity;
$activity = Activity::forSubject($patient)->latest()->first();
$activity->attribute_changes;
// Collection: ['attributes' => ['status' => 'active'], 'old' => ['status' => 'pending']]
$activity->attribute_changes['attributes']; // new values
$activity->attribute_changes['old']; // previous values
Logging a pivot requires a real primary key — add $table->id() in the migration and set $incrementing:
use Illuminate\Database\Eloquent\Relations\Pivot;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
final class ClinicUser extends Pivot
{
use LogsActivity;
public $incrementing = true;
}
For domain events. Keep these calls in actions/services, not controllers.
use App\Models\Patient;
use App\Models\User;
activity('patient') // first arg = log name (optional)
->causedBy($user) // who did it (defaults to auth user)
->performedOn($patient) // the subject
->withProperties(['source' => 'import', 'ip' => request()->ip()])
->event('imported')
->log('Patient imported from CSV');
A login logger (no model subject, just a causer):
use Spatie\Activitylog\Models\Activity;
use App\Models\User;
public function recordLogin(User $user): void
{
activity('auth')
->causedBy($user)
->withProperty('ip', request()->ip())
->event('login')
->log(':causer.email logged in'); // placeholders resolve at log time
}
Placeholders :causer.*, :subject.*, and :properties.* are substituted from the causer model, subject model, and properties array.
Reading a logged activity:
$activity = Activity::all()->last();
$activity->description; // 'Patient imported from CSV'
$activity->causer; // the User model
$activity->subject; // the Patient model
$activity->getProperty('source'); // 'import'
Anonymous events (no causer) and runtime causers in jobs (where there's no authenticated user):
use Spatie\Activitylog\Facades\Activity;
// No causer:
activity()->causedByAnonymous()->log('Scheduled cleanup ran');
// Force a causer for a block of code (e.g. acting on behalf of an admin in a job):
Activity::defaultCauser($admin, function () use ($patient): void {
$patient->update(['status' => 'archived']); // logged with $admin as causer
});
The causer is not carried across the queue boundary — a worker has no authenticated user. Pass the causer into the job and wrap the work in defaultCauser() inside handle():
public function __construct(private readonly Clinic $clinic, private readonly User $admin) {}
public function handle(): void
{
Activity::defaultCauser($this->admin, function (): void {
$this->clinic->update(['is_active' => false]); // attributed to $admin
});
}
Group related activity under a named log so you can query each stream separately. Set it per-model with useLogName() (shown above) or per manual call with the first activity() argument:
activity('billing')->causedBy($user)->log('Invoice generated');
activity('auth')->causedBy($user)->event('login')->log('Logged in');
Query a specific log with the inLog scope (variadic — accepts multiple):
use Spatie\Activitylog\Models\Activity;
Activity::inLog('auth')->get();
Activity::inLog('auth', 'billing')->get();
Activity::inLog(['auth', 'billing'])->get();
log_name groups activities into streams, but for a cross-cutting dimension you filter on
often (e.g. a tenant organisation_id), add a real column and populate it globally.
1. Add the column in a follow-up migration (the package's own migration is already published):
Schema::table('activity_log', function (Blueprint $table): void {
$table->foreignUuid('organisation_id')->nullable()->index();
});
2. Populate it for every activity. Manual and model-event logs both funnel through
LogActivityAction::execute(), which runs every callback registered with
Activity::beforeLogging() right before the row is saved. Register one in a service provider's
boot():
use Spatie\Activitylog\Contracts\Activity as ActivityContract;
use Spatie\Activitylog\Facades\Activity;
public function boot(): void
{
Activity::beforeLogging(function (ActivityContract $activity): void {
/*
* Stamp the acting tenant + request metadata onto every entry.
* ??= so an explicit caller-set value wins.
*/
$activity->organisation_id ??= resolve(OrganisationContext::class)->id();
$activity->properties = $activity->properties->merge(array_filter([
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
]));
});
}
activity() helper — one place to enrich everything.boot(). beforeLogging appends to a static array, so registering
per-request (e.g. in middleware) accumulates duplicate callbacks — matters under Octane.request() inside the closure (write time); never capture them.3. Query the column like any other:
Activity::query()->where('organisation_id', $id)->latest()->get();
The Activity model ships query scopes:
use Spatie\Activitylog\Enums\ActivityEvent;
use Spatie\Activitylog\Models\Activity;
Activity::causedBy($user)->get(); // everything this user did
Activity::forSubject($patient)->get(); // this record's full history
Activity::forEvent('login')->get(); // by event name
Activity::forEvent(ActivityEvent::Updated)->get(); // or the enum
Activity::inLog('patient')->forSubject($patient)->latest()->get(); // chain them
// Custom properties are JSON — query with arrow paths:
Activity::where('properties->source', 'import')->get();
ActivityEvent (Spatie\Activitylog\Enums\ActivityEvent) has Created, Updated, Deleted, Restored.
Disable globally for a block of work (bulk imports, seeders, migrations):
activity()->withoutLogging(function (): void {
Patient::factory()->count(10_000)->create(); // none of these log
});
Toggle manually if you need finer control (re-enable in a finally):
activity()->disableLogging();
// ... work that must not log ...
activity()->enableLogging();
Disable for a single model instance:
$patient->disableLogging()->update(['status' => 'active']); // this write won't log
$patient->enableLogging();
Globally off via env: set ACTIVITYLOG_ENABLED=false (config key activitylog.enabled).
The activity_log table grows fast. Prune records older than activitylog.clean_after_days (default 365):
php artisan activitylog:clean # uses clean_after_days from config
php artisan activitylog:clean --days=30 # override retention for this run
php artisan activitylog:clean auth # only the 'auth' log
php artisan activitylog:clean --force # skip the production confirmation prompt
Schedule it in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('activitylog:clean --force')->daily();
Using the old v4 trait namespace. v5 moved the trait. Import from Models\Concerns:
// ❌ v3/v4 — does not exist in v5
use Spatie\Activitylog\Traits\LogsActivity;
// ✅ v5
use Spatie\Activitylog\Models\Concerns\LogsActivity;
Logging sensitive attributes. logAll() / logFillable() will happily record passwords, tokens, and secrets into the audit table. Always exclude them:
LogOptions::defaults()->logFillable()->logExcept(['password', 'remember_token', 'api_token']);
Noisy, useless logs. Without logOnlyDirty() every update logs every watched attribute even when unchanged, and without dontLogEmptyChanges() you get rows describing zero changes. Use both on automatic logging.
N+1 when reading causer/subject. $activity->causer and $activity->subject are morph relations — lazy loading them in a loop is N+1 per record. Eager load:
Activity::with(['causer', 'subject'])->forSubject($patient)->get();
Pivot without an id. A pivot model using LogsActivity silently won't log unless its table has a $table->id() primary key and the model sets public $incrementing = true;.
Confusing the two before-logging hooks. The per-model beforeActivityLogged(Activity $activity, string $eventName) method mutates one model's activities; the global Activity::beforeLogging(fn ($activity) => ...) facade hook (registered in a service provider) applies to every activity. Use the model method for model-specific tweaks.
Two classes named Activity. Writing logs uses the facade Spatie\Activitylog\Facades\Activity (for defaultCauser, beforeLogging, withoutLogging); reading/querying uses the model Spatie\Activitylog\Models\Activity (for causedBy, forSubject, inLog scopes). If one file needs both, alias one — e.g. use Spatie\Activitylog\Facades\Activity as ActivityLog; — or they collide.
Caching the log name across calls. useLog() / inLog() set log_name on the current entry — not a durable default. After each log() the builder resets, and the next entry's log_name reverts to the configured default (activitylog.default_log_name). So when you wrap the builder in a long-lived service, set the name per entry, never once in the constructor:
// ❌ first entry logs to 'auth'; every later one silently falls back to the default log
public function __construct(PendingActivityLog $log)
{
$log->useLog('auth');
$this->logger = $log->logger();
}
// ✅ apply it on each call
protected function activity(): ActivityLogger
{
$this->pendingLog->useLog('auth'); // forwards to the wrapped logger
return $this->pendingLog->logger();
}
npx claudepluginhub noppu-labs/ai-toolkit --plugin laravelGuides collaborative design exploration before implementation: explores context, asks clarifying questions, proposes approaches, and writes a design doc for user approval.
Creates structured, bite-sized implementation plans from specs or requirements before writing code. Useful for breaking down multi-step tasks into testable steps with file structure and task boundaries.
Resolves in-progress git merge or rebase conflicts by analyzing history, understanding intent, and preserving both changes where possible. Runs automated checks after resolution.