Activity Log Plus
CommunityAn add-on for spatie/laravel-activitylog that records per-language diffs, skips phantom changes, groups each action into one entry and adds a History button to every record.
Author:
asign
Package health
BetaAutomated checks of this plugin's Composer package
15 checks
- Passed: GitHub Actions pinned to SHA
- Skipped: GitLab CI includes pinned to SHA
- Passed: Open security advisories
- Passed: Dependabot PR responsiveness — No stale Dependabot PRs.
- Skipped: Renovate MR responsiveness
- Warning: Dependabot or Renovate configured — Updater does not cover the JavaScript ecosystem, which has a committed lockfile.
- Passed: Dependency update cooldown configured
- Passed: Provides a security policy
- Passed: Abandoned or archived — No consulted source marks the package abandoned (packagist, github).
- Passed: Commit and release recency — Active: last commit 0 days ago; last release 0 days ago.
-
Passed:
composer.lock not committed by library
—
composer.lockis absent from the released dist archive. - Passed: Dist archive is lean
-
Passed:
Current Laravel version supported
—
Package dependencies resolve together with current Laravel
13.0. -
Passed:
Current PHP version supported
—
Constraint
^8.4supports current PHP8.5. - Skipped: Current Symfony version supported
filament/
namespace. Review the source and install at your own risk. Found
malware or an unresolved security issue the author won't
address?
Report it
.
Documentation
- Screenshots
- Requirements
- Installation
- Styling
- Quick start
- Translatable fields
- Phantom changes
- Batches
- Pivot sync
- Media
- Auth events
- Custom events
- Labels
- History action
- Activity log resource
- Authorization
- Pruning
- Configuration
- Translations
- AI agents
- Testing
- License
An audit trail for Filament 5 that finishes the job spatie/laravel-activitylog 5 starts: the engine stores rows, this plugin makes them true and readable on a real, multilingual panel.
Viewers for the activity log already exist; they show what the engine wrote. The trouble is what the engine writes
when a save in a panel is not one save():
- A translatable JSON column shows up as one blob (
title) instead oftitle.uk/title.en. logOnlyDirtyreports columns nobody touched whenever the database has defaults (the phantom changes).- One click on Save is a model
save(), severalsync()s on pivots and a few media writes: a dozen rows, not one action. sync()and media uploads fire no model events on the record at all, so "removed the tag" leaves no trace.- Labels are resolved when the log is read, so a deleted record is "Post #57" and a renamed user changes history.
This plugin fixes each of those in the write layer, and ships the History action and a read-only log resource on top.
- Screenshots
- Requirements
- Installation
- Quick start
- Translatable fields
- Phantom changes
- Batches
- Pivot sync
- Media
- Auth events
- Custom events
- Labels
- History action
- Activity log resource
- Authorization
- Pruning
- Configuration
- Translations
- AI agents
- Testing
#Screenshots
The History button on a record: a field / old / new table per entry, one row per language (Title (uk), Title (en)), and attached / detached tag badges.


The read-only Activity log, "Operations only" filter on: who did what to which record, and which fields changed.

One operation, opened from the log: the record save and the tag sync of the same click, together.

#Requirements
- PHP 8.4+ (spatie/laravel-activitylog 5 requires it)
- Laravel 12 or 13
- Filament 5
spatie/laravel-activitylog5 (installed as a dependency)
Optional: spatie/laravel-medialibrary (file events), spatie/laravel-permission (role events),
spatie/laravel-translatable (picked up automatically; plain JSON columns work too).
#Installation
composer require asignua/filament-activity-log-plus
Publish one of the two migrations and run it. The table name comes from activity-log-plus.table
(default activity_log); change it before you publish.
php artisan vendor:publish --tag=filament-activity-log-plus-migrations
| You are... | Run |
|---|---|
| starting fresh | create_activity_log_plus_table (the full table; delete the other file) |
already using spatie's activity_log |
add_activity_log_plus_columns (adds subject_label, causer_label, batch_uuid, batch_root, ip and an index; delete the other file) |
php artisan migrate
Existing rows keep batch_root = false, so the default "Operations only" filter hides them; switch it off in the
log, or mark them once with UPDATE activity_log SET batch_root = 1 WHERE batch_uuid IS NULL.
Optionally publish the config:
php artisan vendor:publish --tag=filament-activity-log-plus-config
Register the plugin in your panel provider and say who may read the log:
use Asignua\FilamentActivityLogPlus\ActivityLogPlusPlugin;
$panel->plugin(
ActivityLogPlusPlugin::make()
->authorizeResource(fn (): bool => auth()->user()?->isAdmin() ?? false),
);
The log resource is closed by default. Without authorizeResource() and without the activity-log-plus.view gate
nobody sees it, because the feed holds every user's actions, IPs and the addresses typed into the login form (see
Authorization). The History action on a record stays open by default.
What the plugin sets in spatie's config. Recording works without the panel. On boot the plugin sets
activitylog.activity_model and activitylog.actions.log_activity to its own classes, but only while they are still
spatie's defaults. If your application (or another package) bound its own, it is left alone: extend
Asignua\FilamentActivityLogPlus\Models\Activity and Asignua\FilamentActivityLogPlus\Actions\LogActivityAction
there to keep the batch, the labels and the IP. It also turns activitylog.enabled off when the plugin is disabled and
adds password / remember_token to activitylog.default_except_attributes as a safety net.
The IP is stored with every entry, not only with authentication events: any entry written during a request gets
request()->ip() in the ip column. It is personal data; cover it in your privacy notice and keep retention_days
sensible. spatie's default_except_attributes filters model diffs only, never withProperties() of a hand-written
entry, so do not pass secrets there.
spatie's buffer (activitylog.buffer.enabled) is honoured outside a batch only: inside one (a panel request) the
entries are written straight away, because the root of an operation needs the key of the row it may demote.
#Styling
The views use a few Tailwind utilities that Filament's own stylesheet does not contain. The plugin ships them as a small compiled file (resources/dist/filament-activity-log-plus.css, no preflight) and links it after the panel's styles, so no custom theme or @source line is needed. Publish the file after installing or upgrading:
php artisan filament:assets
The stylesheet is linked by the plugin registered in the panel (the History action rendered in a panel without the plugin stays unstyled). Editing the views? Rebuild with npm install && npm run build.
#Quick start
use Asignua\FilamentActivityLogPlus\Concerns\LogsActivityPlus;
class Post extends Model
{
use LogsActivityPlus; // instead of spatie's LogsActivity, which it includes
}
That is all for a model. It logs created / updated / deleted / restored, with logAll() (so a model with
$guarded = ['*'], written only through repositories, is logged too), only the dirty attributes, no empty entries and
no secrets (except config: id, password, remember_token, created_at, updated_at). "No empty entries" holds
after the trait's own filtering too: an update whose diff is empty once phantom columns and unchanged locales are cut
(a translatable column re-encoded with the same values) writes nothing.
Three optional protected hooks:
protected function activityExcept(): array { return ['api_secret']; } // your own secrets
protected function activityTranslatableAttributes(): array { return ['title']; } // see below
protected function activityIgnoreOnlyChanged(): array { return ['path']; } // derived columns
Override activityExcept(), never activityExceptAll(): the latter comes from the trait, and a full override would
silently drop the built-in exclusions.
#Translatable fields
A translatable column is a JSON map such as {"uk":"Привіт","en":"Hello"}. The log stores the diff per locale and
drops the locales that did not change:
{"attributes": {"title.en": "Hello!"}, "old": {"title.en": "Hello"}}
- With spatie/laravel-translatable nothing is needed: the trait reads
getTranslatableAttributes(). The trait also switchesuseAttributeRawValues()on for those columns, because the package overridesgetAttribute()and would hand over the string of the current locale, so an edit in another language would vanish from the diff. - With plain JSON columns (an
arraycast or a raw string) declare them:
protected function activityTranslatableAttributes(): array
{
return ['title', 'summary'];
}
The expansion is plain static logic in Support\ActivityDiff (expandTranslations, onlyColumns, truncate, isEmpty,
changedKeys), usable on its own.
#Phantom changes
spatie's logOnlyDirty compares the model with getRawOriginal(), which does not know the defaults of the
database. Create a record and update it in the same request and every column with a default (order, noindex...)
is null in memory but 0 / false in the table, so a "change" of fields nobody touched lands in the diff.
On updated the trait therefore cuts the diff down to array_keys($model->getChanges()): exactly the columns that went
into the UPDATE (Eloquent syncs them before the updated event fires).
Also bounded: values longer than max_value_length (5000) are cut with … and listed in
attribute_changes.truncated (a non-translatable JSON array is measured, and cut, as its JSON text), otherwise every
edit of a rich-text field stores two HTML blobs; and an update that
changed only columns from ignore_only_changed / activityIgnoreOnlyChanged() writes nothing (typical: a
materialised path rewritten on every ancestor save, which would log one row per descendant).
Hooks that save other records on save must be silenced, or one edit becomes dozens of rows:
activity()->withoutLogging(fn () => $this->rebuildLinks());
Do the same in bulk imports and seeders.
#Batches
One operation = one batch_uuid. The entry that opens the operation (normally the record's own save, otherwise the
first pivot/media entry) is the root (batch_root = true); the log shows roots by default and the modal lists
every part.
- Panel requests: the plugin adds
ActivityBatchMiddlewareto the panel, and the service provider hooks Livewire'srequestevent (persistent middleware cannot wrap a Livewire update: Livewire runs it before it calls the component). Turn the middleware off withActivityLogPlusPlugin::make()->batchMiddleware(false), or both with thebatchesconfig. - Console, queue, tests: there is no request, so every entry is its own root. Wrap the work yourself:
app(ActivityBatch::class)->run(function () use ($post): void {
$post->update([...]);
$post->syncAndLog('tags', $ids);
});
ActivityBatch is a scoped binding (one per request, Octane-safe) and run() restores the previous state, so it nests.
#Pivot sync
sync() fires no Eloquent events, so a changed relation is invisible to the engine. Two ways in, both writing one
entry against the owner (so it reads on the record's own history):
use Asignua\FilamentActivityLogPlus\Concerns\LogsPivotSync;
class Post extends Model
{
use LogsActivityPlus;
use LogsPivotSync;
}
$post->syncAndLog('tags', [1, 2, 3]); // returns sync()'s result
// or, for anything else (attach/detach, a custom pivot):
PivotSynced::dispatch($post, 'tags', attached: [3], detached: [1]);
Properties: {key, label, attached: [{id, title}], detached: [{id, title}]}, event pivot_synced (pass event: to use
your own name). The titles are stored at the moment of the action: ids alone would leave the log blank on the day
someone renames or deletes the related record. By default they come from the related model through SubjectLabels;
pass titles: [id => title] to the event, or replace the resolver globally:
PivotTitles::using(fn (string $relation, array $ids, Model $owner): array => [...]); // [id => title]
A raw $post->tags()->sync() stays invisible. If nothing was attached or detached, nothing is written.
#Media
With spatie/laravel-medialibrary installed, uploads and removals are logged as media_added / media_removed
(properties collection, file_name, name, mime_type, size) against the owner of the file. Filament writes
media directly (SpatieMediaLibraryFileUpload), past your DTOs and repositories, so "removed the picture" would
otherwise leave no trace; the batch stitches it to the form save. Switch it off with log_media.
Another library, or an "attach a file from a shared library" model, plugs in with an adapter:
MediaAdapters::register(
MediaAttachment::class,
owner: fn (MediaAttachment $a): ?Model => $a->attachable,
properties: fn (MediaAttachment $a): array => ['name' => $a->file?->name],
events: ['created' => 'media_attached', 'deleted' => 'media_detached'],
);
#Auth events
log_auth (on by default) records login, logout, login_failed (with the attempted address, never the password)
and lockout in the auth log, with the IP. A failed attempt against an existing account has that account as its
subject and no causer: whoever typed the password is unknown, and the victim must not appear as the actor. With spatie/laravel-permission it also records role_attached /
role_detached by role name; that package sends no events unless permission.events_enabled is on, so the plugin
switches it on when log_auth is. A role lives in a pivot, so without this "promoted to administrator" would never be
logged. The volume is bounded by pruning.
#Custom events
Write the entry against the owner, with the names denormalised at write time:
activity()
->performedOn($application)
->event('approved')
->withProperties(['note' => 'Looks fine'])
->log('approved');
Then teach the log how to present it (an unregistered event is shown as its raw name, in grey):
ActivityEvents::register(
'approved',
'Approved', // a translation key or a ready string, or a closure
color: 'success', // a Filament colour
summary: fn (Activity $a): string => (string) $a->getProperty('note'),
view: 'my-app::activity.approved', // optional: replaces the body of the card
);
Built in: created, updated, deleted, restored, pivot_synced, media_added, media_removed, login,
logout, login_failed, lockout, role_attached, role_detached. Registering a built-in name replaces it. An entry
whose properties have the pivot shape (attached and detached lists) is rendered as badges without registering
anything; any other properties are shown as a definition list. A custom view receives $entry and $properties.
#Labels
Labels are stored with the entry, in the current locale, at the moment of the action: subject_label,
causer_label. The log survives the deletion of a record or a user.
- Records:
SubjectLabels::using(fn (Model $model): ?string => ...)(returnnullto fall through), thengetFilamentName(), then the first filled oftitle,name,label,key,email,path,old_path(a translatable one in the current locale, then the fallback locale, then any filled one), thenPost #57. Limited to 190 characters. - Types: the
subjectsconfig,Post::class => 'Posts'(a translation key or a ready string); unlisted models show their short class name. - Fields: the
field_labelsconfig orFieldLabels::register([...]); the key is the column, sotitlealso labelstitle.ukandtitle.en(the locale is appended: a bare "Title" on a two-language site does not say which changed). Unknown columns fall back toStr::headline(). The config wins over code.
#History action
use Asignua\FilamentActivityLogPlus\Actions\ActivityHistoryAction;
protected function getHeaderActions(): array
{
return [ActivityHistoryAction::make(), DeleteAction::make()];
}
A modal with the latest history_limit (50) entries of this record, newest first: event badge, field / old / new
value (rich text shown as plain text, (Truncated) where a value was cut), attached / detached badges, and a
definition list for other properties. Filament has no global hook for header actions, so it is one explicit line per
Edit/View page. It works without the log resource.
#Activity log resource
A read-only resource (create, edit and delete are closed at the resource level, not just hidden): time, user (searchable; "System" when there is none), event badge, type (toggleable), record and a one-line summary. Filters: Operations only (on by default), event, type and user (taken from the values that really occur in the table) and a period. The row action opens the whole operation. Sorted by id descending, 25/50/100 per page.
ActivityLogPlusPlugin::make()
->navigationGroup('System')
->navigationSort(8)
->navigationIcon('heroicon-o-clipboard-document-list')
->resource(false); // History action only
A bulk action over many records is one operation with one root, so its row reads +49 more after the summary; the
modal shows the first 200 entries of an operation and says so when there are more. The filter options (event, type,
user) are cached for 60 seconds. The resource is not globally searchable.
#Authorization
The feed shows other people's actions, IPs and the addresses of failed logins, so it is closed by default:
ActivityLogPlusPlugin::make()
->authorizeResource(fn (): bool => auth()->user()?->isAdmin()) // the log resource
->authorizeHistory(fn (): bool => true); // the History action
Without a closure the plugin consults the gates activity-log-plus.view and activity-log-plus.history:
Gate::define('activity-log-plus.view', fn (User $user): bool => $user->isAdmin());
With neither a closure nor the gate, the log resource is denied to everyone and the History action is allowed to everyone who can open the record. The two are separate on purpose: whoever may edit a record should see who changed it before, even without access to the global log.
#Pruning
php artisan activity-log-plus:prune # retention_days (365)
php artisan activity-log-plus:prune --days=90
retention_days = 0 disables it. Deletion runs in chunks of 1000, so it never holds a long lock. A daily run is a
config switch (needs the Laravel scheduler):
'schedule' => ['enabled' => true, 'time' => '03:30'],
#Configuration
Every key of config/activity-log-plus.php is commented. In short: enabled, table, retention_days,
max_value_length, history_limit, log_auth, log_media, batches, except, ignore_only_changed,
field_labels, subjects and schedule. The log columns are cast with Casts\UnescapedJsonCollection, not the stock
collection cast: that one writes До... for non-ASCII text, which makes the table, dumps and backup diffs
unreadable. Reading accepts both formats.
#Translations
The interface ships in English, Ukrainian, German, Spanish, French, Italian, Dutch, Polish, Brazilian Portuguese and
Turkish under the filament-activity-log-plus::activity-log-plus namespace. A test keeps every language in step with
the English keys. Override a string by publishing the translations and editing the copy in
lang/vendor/filament-activity-log-plus.
#AI agents
The package ships Laravel Boost guidelines
(resources/boost/guidelines/core.blade.php) that describe the trait, batches, pivots, media and the registries, so a
coding agent wires an audit trail up correctly.
#Testing
composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/pint --test
The suite runs on Orchestra Testbench with a workbench/ panel: a model with
translatable JSON fields without spatie/laravel-translatable, one with it, a BelongsToMany pivot, media and
roles.
#License
The MIT License (MIT). See LICENSE.md.
The author
asign is a small web-dev company from Lviv, Ukraine. We build business applications on Laravel and Filament — CRMs, automation systems for standard and non-standard business processes, booking and content management systems, including our own Filament-based CMS. We open-source the parts that prove useful beyond a single project
From the same author
Relation Manager Tabs
Render relation managers as ordinary tabs of the record form, so an edit or view page has exactly one row of tabs.
Author:
asign
SEO Files
Generate and edit sitemap.xml with hreflang and a sitemap index for large sites, robots.txt and llms.txt / llms-full.txt from your Filament panel
Author:
asign
Chat
Team chat for your panel: direct messages and groups, @mentions, reactions, read receipts, a pinnable slide-over, and messages that link to your panel's records.
Author:
asign
Image Meta
Per-file alt text, a decorative flag, caption, title, and a focal point for Filament 5's own FileUpload and SpatieMediaLibraryFileUpload: one wrapper around the upload you already have, no media system to adopt.
Author:
asign
Featured Plugins
A selection of plugins curated by the Filament team
Blueprint
Filament Blueprint is a premium Laravel Boost extension that helps AI agents produce accurate, detailed implementation plans and security reports for Filament apps.
Filament
Soft Theme
A theme that gives panels a warm, approachable look with rounded shapes, gentle colors, and serif headings.
Filament
Advanced Tables (formerly Filter Sets)
Supercharge your tables with powerful features like user-customizable views, quick filters, multi-column sorting, advanced table searching, convenient view management, and more. Compatible with Resource Panel Tables, Relation Managers, Table Widgets, and Table Builder!
Kenneth Sese