agentsclimarketplace

Laravel activity logging

Skill noppu-labs/ai-toolkit/laravel/skills/laravel-activity-logging

Toolkit for agentic development of Laravel & React apps

Install
npx -y skills add noppu-labs/ai-toolkit --skill laravel-activity-logging

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

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.

SKILL.md

15.1 KB, as published. Nobody here has run it

Laravel Activity Logging

Audit trails with spatie/laravel-activitylog v5. Records who did what to which model, with what changed.

Related guides:

  • Models - The trait lives on Eloquent models
  • Enums - ActivityEvent is a backed enum
  • Services - Manual logging belongs in actions/services, not controllers

Overview

There are two ways to log:

  1. Automatic model events — add the LogsActivity trait to a model and it logs created / updated / deleted / restored for you, recording attribute changes.
  2. Manual logging — call the 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.

When to Use

Use automatic model logging when:

  • You want a change history on a model (who edited a Patient, what fields changed)
  • Compliance/audit requirements on sensitive records

Use manual logging when:

  • The event isn't a model write (user logged in, report downloaded, webhook received)
  • You need a human-readable description with custom properties

Quick Reference

OperationCode
Log a model automaticallyuse LogsActivity; + getActivitylogOptions()
Log only changed attributesLogOptions::defaults()->logOnly([...])->logOnlyDirty()
Exclude sensitive fields->logExcept(['password', 'remember_token'])
Suppress no-op updates->dontLogEmptyChanges()
Manual logactivity()->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 logactivity('auth')->log('...')
Read what changed$activity->attribute_changes (['attributes'=>..., 'old'=>...])
Query by causerActivity::causedBy($user)->get()
Query a subject's historyActivity::forSubject($patient)->get()
Disable for a blockactivity()->withoutLogging(fn () => ...)
Set causer in a jobActivity::defaultCauser($admin, fn () => ...)
Prune old recordsphp artisan activitylog:clean --force

Automatic model logging (LogsActivity trait)

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 SoftDeletesrestored 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());
}

Reading what changed

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

Pivot models

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;
}

Manual logging (activity() helper)

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
    });
}

Multiple logs

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();

Grouping by a custom column & global enrichment

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(),
        ]));
    });
}
  • Fires for the trait and the activity() helper — one place to enrich everything.
  • Register once in boot(). beforeLogging appends to a static array, so registering per-request (e.g. in middleware) accumulates duplicate callbacks — matters under Octane.
  • Resolve services and request() inside the closure (write time); never capture them.

3. Query the column like any other:

Activity::query()->where('organisation_id', $id)->latest()->get();

Querying activities

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.

Disabling logging

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).

Cleaning up

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();

Common Mistakes

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();
}

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.