muhammadsadeeq/laravel-activitylog-ui
Beautiful modern UI for Spatie Laravel Activity Log. Browse activities in table, timeline, and analytics views; filter by date, event, user, or subject; save views; export CSV, Excel, PDF, or JSON; and control access via gates and middleware. Built with Tailwind and Alpine, no build step.
Prerequisites (non-negotiable). This package is a viewer, not a logger. Before installing, confirm:
spatie/laravel-activitylog ≥ 5.0 already installed, migrated, and producing rowsactivity_log table exists with the v5 schema, including the attribute_changes columnIf you are on Spatie v4 or have unmigrated rows, upgrade Spatie first. v2.0+ of this package reads attribute diffs from attribute_changes, not properties.
Install and boot.
composer require muhammadsadeeq/laravel-activitylog-ui
# Config — do this first, before you show it to anyone
php artisan vendor:publish --provider="MuhammadSadeeq\ActivitylogUi\ActivitylogUiServiceProvider" --tag="activitylog-ui-config"
# Recommended on any non-trivial dataset (v3.0.0+: adds indexes for newest-first listing and event grouping)
php artisan vendor:publish --tag="activitylog-ui-migrations"
php artisan migrate
Then visit /activitylog-ui. No build step, no npm, no asset compilation — the package serves its own versioned stylesheet.
Where to look first, in order:
config/activitylog-ui.php — the published file is the source of truth; the README's snippet is only a subset.authorization.enabled — defaults to true as of v3.0.0. Confirm this matches your intent before deploying; see Gotchas.Gate::define('viewActivityLogUi', ...) in your AppServiceProvider if you want real authorization rather than the email/role lists.route.prefix and route.middleware if the UI needs to live behind an existing admin prefix.First use case. Log in as an admin, open the table view, and confirm three things: rows render with readable causer names, the date-range + event filters return sensible counts, and the Analytics tab loads. That triple verifies DB connectivity, Spatie v5 schema compatibility, and the cache driver — the three things that break first.
Gate-based authorization (the pattern worth standardizing on). Rather than maintaining allowed_users email lists, define the gate in your own code and let your existing policy layer decide:
// AppServiceProvider::boot()
Gate::define('viewActivityLogUi', fn (User $user) => $user->hasRole('admin'));
Set authorization.enabled => true and leave access.allowed_users / access.allowed_roles empty. Note the precedence: allowed_users and allowed_roles are additional openers, not restrictions on top of the gate — read the authorization section of the docs before combining them, because the ordering is not intuitive.
Middleware layering. route.middleware replaces the base stack only; the package appends its own auth and access middleware afterwards. So this is safe and common:
'route' => [
'prefix' => 'admin/activitylog',
'middleware' => ['web', 'verified'],
],
You do not need to re-add auth — v3 does it for you.
Log writes for the UI, not just for the DB. The filter panel is only as good as your log hygiene. The filters key off log_name, event, causer, subject, and free-text search across description, properties, and attribute_changes. So:
activity('billing') // log_name → filterable
->performedOn($invoice)
->causedBy($user)
->event('refunded') // event → filterable + analytics grouping
->withProperties([
'amount' => $invoice->total,
'gateway' => 'stripe',
'reference' => $refund->id, // searchable via properties
])
->log('Invoice refunded');
Keep properties small and non-sensitive. Everything in properties and attribute_changes is rendered in the detail modal and returned by export endpoints — free-text search will surface it to whoever has UI access.
Exports. CSV and JSON work with zero extra dependencies. Add the optional packages only if you actually need the formats:
composer require maatwebsite/excel barryvdh/laravel-dompdf
For anything past a few thousand rows, enable the queue — synchronous PDF generation over 10k records will time out and leave the user staring at a spinner:
'exports' => [
'enabled_formats' => ['csv', 'xlsx', 'json'], // drop 'pdf' if dompdf is absent
'max_records' => 10000,
'queue' => [
'enabled' => true,
'threshold' => 1000,
'queue_name'=> 'exports',
],
],
Run a worker on the exports queue. Exports are scoped to their creator in v3 — a link shared with a colleague will 403.
Saved views as team conventions. Saved views persist filters and per-page/sort preferences. Use them to codify recurring investigations — "failed logins, last 7 days", "all billing events by user X" — and point people at the saved view instead of re-deriving filters. They are pure UI state; they don't affect access control.
Analytics caching. Stats are cached for analytics.cache_duration (default 3600s). If the analytics tab looks stale during an incident, that's the cache, not a bug. Either shorten the duration or clear the cache explicitly. Disable the feature entirely via features.analytics => false if you don't want the extra aggregate queries on a large table.
Local vs. production config. Keep authorization.enabled on everywhere and grant yourself via the gate in local; do not reach for ACTIVITYLOG_UI_AUTHORIZATION=false to "just check something" — it's the exact flag that made v2 installs public.
The v3 authorization default is the one thing that will bite you. Installs that published the config keep their own value; installs that never published it now require a logged-in user. If the UI suddenly redirects to /login, that's the change, not a bug. Conversely, if you upgraded from v2 and the config was published with authorization.enabled => false, you are still public — and note that allowed_users/allowed_roles were silently non-functional in that combination before v3, so your presumed allowlist may never have applied.
422s are now a feature. v3 refuses out-of-range or unusable parameters instead of clamping them: per_page=999999 returns 422 rather than silently becoming 100; an unparseable causer_id returns 422 rather than dropping the filter. This is deliberate — a dropped filter shows more of the audit log than requested. If you're calling the JSON endpoints from your own dashboard, handle 422 and surface the validation message; don't retry.
Pagination anchoring is paired. The list endpoint requires anchor_id and anchor_time together, and anchor_time carries microseconds. Echo back the exact value you were given; reformatting it breaks the anchor. Clients using the shipped UI are unaffected.
Silent export fallbacks. Missing maatwebsite/excel → XLSX quietly becomes CSV. Missing barryvdh/laravel-dompdf → PDF becomes JSON. Users will report "the PDF is a text file." Either install the packages or remove the formats from exports.enabled_formats so the buttons disappear.
attribute_changes vs. properties is a real split in v2+. The UI shows them as distinct sections, and keeps a legacy fallback that resolves from properties.old / properties.attributes for rows written before the migration. If a detail modal looks empty for old rows but fine for new ones, you're seeing the fallback path. Also, the legacy diff keys are filtered out of the Custom Properties panel to avoid duplication — don't add your own old/attributes keys to properties, they'll vanish.
Sorting is on id, not created_at. This is a deliberate perf choice (v1.2.0) and it matters if your primary key isn't monotonic — UUID/ULID keys sort by insertion order, not by log timestamp, so a backfilled activity will appear at the bottom of the list. v3 removed hasMonotonicKey() and now anchors on (created_at, key) to reduce the key to a tiebreak.
The index migration locks activity_log. It's opt-in because the table is Spatie's and indexing an established one blocks writes while it runs. On ~200k rows the maintainer measured first-page load dropping from 350ms to 21ms. Schedule it in a maintenance window, not during a deploy that also writes activity.
Debugging a blank table or spinning loader. Since 2.0.1, non-JSON API responses are surfaced with clearer diagnostics rather than failing as a generic parse error — open the network tab and read the actual response body. The usual culprits: your auth middleware returning an HTML login redirect to a fetch(), a CSRF/session mismatch from a custom middleware stack, or a 422 from a bad filter parameter.
Publishing views pins you to a version. --tag="activitylog-ui-views" copies Blade files into your app; subsequent package upgrades won't touch them, and v2's timeline/modal reworks may leave your copies stale or referencing removed Alpine state. Prefer config + CSS overrides; publish views only when you must, and re-diff on every minor bump.
Causer resolution deliberately bypasses global scopes. Since 1.3.0, related users are loaded without global scopes — which is what you want for audit integrity (a soft-deleted or tenant-scoped user still shows as the causer), but it also means a causer name may resolve to a record your app's normal queries would never return. Don't be surprised by "unknown" users from other tenants appearing in a shared audit view.
Real-time counts come from the cache. The "count & pagination powered by cache" feature means counts can lag writes. With a database or array cache store on multiple app servers, the count is per-server-consistent-at-best. Use Redis if you care about the live count being right.
Pruning is Spatie's job. The UI renders whatever is in activity_log; it doesn't rotate or delete. Pair it with Spatie's activitylog:clean command on a schedule, then re-check that your analytics.cache_duration doesn't outlive your retention window — otherwise the chart shows events whose rows have already been pruned.
How can I help you explore Laravel packages today?