Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nova Activitylog Laravel Package

bolechen/nova-activitylog

A Laravel Nova integration for spatie/laravel-activitylog that lets you browse, filter, and inspect activity logs and model changes directly from your Nova dashboard.

View on GitHub
Deep Wiki
Context7

Getting Started

This package is the read side of spatie/laravel-activitylog. It adds a Nova screen that lists rows already sitting in activity_log. If nothing is writing to that table, the screen is empty and there is nothing to debug in the Nova layer. Get the writer working first, then hang the viewer off it.

Minimal path, in order:

# 1. The engine — this is what actually records anything
composer require spatie/laravel-activitylog
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations"
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-config"
php artisan migrate

# 2. Prove a row lands before installing any UI
php artisan tinker --execute="App\Models\Invoice::first()->update(['status' => 'paid']);"
php artisan tinker --execute="dump(DB::table('activity_log')->latest('id')->first());"

# 3. Only now the Nova integration
composer require bolechen/nova-activitylog
php artisan vendor:publish --provider="Bolechen\NovaActivitylog\ToolServiceProvider"   # verify provider name in vendor/ before running

Register the tool in app/Providers/NovaServiceProvider.php and gate it on the same line you create it — do not ship it ungated even for a day "while you test":

public function tools()
{
    return [
        \Bolechen\NovaActivitylog\NovaActivitylog::make()
            ->canSee(fn () => Gate::allows('viewAny', \Spatie\Activitylog\Models\Activity::class)),
    ];
}

Where to look first, in this order:

  • vendor/bolechen/nova-activitylog/src/ — the tool class. Read canSee(), menu(), and the props handed to the Vue component. This tells you whether filtering and authorization are implemented or merely described in the README.
  • vendor/bolechen/nova-activitylog/resources/js/ — the list component. This answers what the docs don't: is filtering done in SQL or in the browser? Are causer/subject eager-loaded, or does every row trigger two queries? Is pagination server-side?
  • composer.json constraints — which Nova major and which spatie/laravel-activitylog major are declared. Pin both.
  • config/nova-activitylog.php, if it publishes — navigation label, authorization hook, defaults.

Your first use case should not be "browse all activity." A global feed of every row is a demo, not a workflow. Ship the record history flow first: a support engineer on an invoice dispute filters by subject, sees status: pending → paid by user 412 at a timestamp, and closes the ticket without opening a DB console. That one flow pays for the install. If it doesn't come up in the first week or two, neither will anything else this tool offers.


Implementation Patterns

Make log_name your domain axis

The panel's log-name filter is worth exactly as much as your naming discipline. Name by business domain, not by model class:

->useLogName('billing')      // invoices, payments, refunds
->useLogName('auth')         // logins, password resets, MFA changes
->useLogName('admin')        // roles, permissions, impersonation
->useLogName('integrations') // webhooks, sync jobs

A class-based name (App\Models\Invoice) duplicates subject_type and gives you a filter that splits one concept into five buckets. Domain names let an admin ask "what privileged actions happened in the last 24 hours" with two clicks.

Log the events that never touch a model

Most of what belongs in an audit panel is not an Eloquent event: logins, exports, permission grants, impersonation, settings toggles. Log those explicitly, with a consistent payload shape:

activity('admin')
    ->causedBy($actor)
    ->performedOn($targetUser)
    ->event('role_changed')
    ->withProperties(['from' => 'editor', 'to' => 'admin', 'via' => 'nova'])
    ->log('changed role');

Adopt the convention and hold it: <domain> log name + snake_case event + from/to properties. The event filter and the property viewer are only usable six months later if the shape is predictable.

Resolve the causer centrally, and cover the two gaps

Spatie resolves the causer from the default guard. That fails silently in two places you will hit:

  • Queued jobs — no authenticated user in the worker, so causer is null. Pass the actor explicitly with ->causedBy($user).
  • Console commands and imports — same problem, every row renders blank in the panel, which is worse than no row.

Handle it in one place rather than at every call site: set default_causer in config/activitylog.php, or register a causer resolver. If your app has more than one guard (Nova admin vs. customer API), verify which one wins — the wrong guard makes the whole panel misleading, not just incomplete.

Treat properties as a deliberate, small snapshot

Everything in properties is rendered verbatim to anyone who can see the panel.

->logOnly(['status', 'total', 'assigned_to'])        // not logAll, not ['*']
->logExcept(['password', 'api_token', 'remember_token'])
->logOnlyDirty()
->dontSubmitEmptyLogs()

logAll() on a User writes password hashes and API tokens into an append-only table with a wide internal read audience. And logOnlyDirty() + dontSubmitEmptyLogs() are effectively mandatory: without them every updated_at touch and every no-op save() produces a row, and the panel degenerates into a stream of empty diffs that nobody scrolls.

Attach correlation context with a tap

For day-to-day incident triage, this is the highest-leverage five lines in the integration — it turns "user 412 changed the status" into a joinable handle against Sentry, web server logs, and support tickets:

// config/activitylog.php
'tap' => [App\Support\Activity\CorrelationTap::class],
class CorrelationTap
{
    public function __invoke(Activity $activity): void
    {
        $activity->properties = $activity->properties->merge([
            'request_id'      => request()?->header('X-Request-Id'),
            'ip'              => request()?->ip(),
            'impersonator_id' => session('impersonated_by'),
        ]);
    }
}

Collapse bulk operations on purpose

A loop of find($id)->update([...]) over 5,000 records writes 5,000 rows and buries everything else in the panel. Choose explicitly rather than by accident:

activity('billing')
    ->causedBy($admin)
    ->event('bulk_status_update')
    ->withProperties([
        'count'     => $count,
        'to'        => 'paid',
        'ids_sample'=> array_slice($ids, 0, 50),
    ])
    ->log('bulk updated invoices');

Aggregate for internal operational actions; keep per-record logging for anything a customer could later dispute.

Put history where the question is asked

The global browse screen is one consumer of the table, not the only one. A "history" panel on the resource detail page is usually more valuable day to day — it puts the answer next to the record instead of in a separate menu:

Activity::forSubject($invoice)->causedBy($user)->latest()->limit(50)->get();

Same query powers a Slack alert or a scheduled digest. The schema is the asset; the Nova screen is one view over it.

Instrument the Nova side too

Custom Nova actions, tools, and resource actions are ordinary Eloquent writes, so model-level instrumentation covers them automatically. But a custom action that touches several models or drops to the query builder is an audit hole exactly where your most privileged operations live — log those explicitly.


Gotchas and Tips

An empty panel is missing instrumentation, not a broken package. Before reading any package source, run DB::table('activity_log')->count(). Most "it doesn't work" reports end at that query.

Raw and mass updates bypass logging entirely. DB::table(...)->update(...), Model::query()->update(...), and raw upserts never fire model events, so no rows are written. Reports, imports, and sync jobs built on the query builder create invisible gaps in the audit trail. Route those writes through model instances or log the operation manually.

Decide your morph map on day one. If you add Relation::enforceMorphMap() after months of logging, historical rows hold FQCNs while new rows hold aliases — the subject filter and relation loading mismatch silently across the boundary, and fixing it is a data migration. Choose before the first row.

Force-deleted subjects render as empty cells. Soft deletes keep the relation resolvable; forceDelete() leaves subject_type/subject_id pointing at nothing and the list shows a blank subject. That's expected behaviour, and it's the strongest argument for keeping a logOnlyDirty property snapshot as your fallback.

Casts look wrong in the diff. old/new are JSON-serialized, so a cast date shows as "2025-04-09T00:00:00.000000Z" and an enum as its backing value. Reviewers will file "the diff is wrong" bugs. Normalize in the tap or document the behaviour — don't try to fix it per-model.

Check pagination and indexes before rollout, not after. activity_log is append-only; a busy app reaches millions of rows within a year. Confirm the Vue component paginates server-side, then verify you have the indexes:

$table->index(['subject_type', 'subject_id']);
$table->index(['causer_type', 'causer_id', 'created_at']);
$table->index(['log_name', 'created_at']);
$table->index('created_at');

Load-test against a realistic row count. If the query plan is bad, replace the UI — never the schema.

Schedule pruning from the start. Spatie ships activitylog:clean, driven by delete_records_older_than_days. Put it in the scheduler in the same PR as the migration. The retention window is a policy decision for security/legal, not something the package can pick for you.

Authorization is the highest-severity open question, not a detail. Test by hand: log in as a low-privilege Nova user and see whether they can read every row, including other tenants' activity. If canSee() is the only gate, access is all-or-nothing — add a Nova policy or scope the query yourself. Do not infer the behaviour from the README; read the component's query.

Wrap seeders and backfills. Activity::withoutLogging(fn () => ...) around test-data refreshes and one-off backfills. Otherwise a staging refresh dumps 200k rows into an audit table you then have to explain to someone.

Test the instrumentation, not just the UI. Activity::fake() lets you assert logging without touching the table, so a refactor that quietly kills your audit trail fails CI instead of showing up as an empty panel weeks later:

Activity::fake();
$invoice->update(['status' => 'paid']);
Activity::assertLogged('updated', fn ($a) => $a->subject_id === $invoice->id);

Pin both versions and be ready to fork. With effectively no dependents, you are the first integration test for any Nova × Spatie combination. Constrain laravel/nova and spatie/laravel-activitylog exactly, treat a bump to either as a change that requires regressing this panel, and assume you'll copy the ~1 tool class plus Vue component into your app. It's small enough that vendoring proactively is cheaper than reacting to an incident. Assign a named owner and a reminder to re-verify after each Nova major bump — thin Vue wrappers go stale silently between majors.

Don't let this become your compliance story. The panel is read-only convenience: no immutability, no export, no legal hold, no SLA. If a contract or regulator needs audit trails, that's a separate feature scoped on its own terms. The durable asset is the Spatie logging layer plus your retention and index decisions — not this screen.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource