abdulbaset / laravel-activity-tracker
Automatic, zero-code database activity/audit tracker for Laravel applications.
Package info
github.com/AbdulbasetRS/laravel-activity-tracker
pkg:composer/abdulbaset/laravel-activity-tracker
Requires
- php: ^8.1
- illuminate/auth: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/queue: ^10.0|^11.0|^12.0
- illuminate/routing: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- illuminate/validation: ^10.0|^11.0|^12.0
- illuminate/view: ^10.0|^11.0|^12.0
- symfony/http-kernel: ^6.0|^7.0
Requires (Dev)
- laravel/pint: ^1.16
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^10.0|^11.0
- pusher/pusher-php-server: ^7.0|^8.0
Suggests
- pusher/pusher-php-server: Required for live broadcast channel/connection statistics when using the "pusher" or "reverb" broadcasting driver.
Provides
None
Conflicts
None
Replaces
None
README
اقرأ هذا الملف بالعامية المصرية
Automatic, zero-code database activity/audit tracking for Laravel applications.
abdulbaset/activity-tracker observes your application's Eloquent models and
database queries and turns them into normalized activity records — without
you adding a trait, an observer, or a single line of code to your Models,
Controllers, Services, or Repositories.
composer require abdulbaset/activity-tracker php artisan migrate
That's it. Tracking begins immediately.
Table of contents
- What it does
- Why it exists
- Installation
- Automatic activation
- Configuration
- Database migration
- Tracked operations
- Retrieval strategy & internal reads
- Retrieval tracking
- Bulk operations
- Query tracking
- Duration & performance
- Full URL, path & referrer
- Exception tracking
- Authentication event tracking
- Broadcast monitoring
- Sensitive data protection
- Ignoring models
- Authentication / causer tracking
- Request metadata
- Queue support
- Transactions
- Reading activities
- Admin dashboard
- Class naming conventions
- Events
- Extending the package
- Performance considerations
- Limitations
- Troubleshooting
- Testing
- Contributing
- License
What it does
The package hooks into two layers of your application automatically:
- Eloquent lifecycle events (
eloquent.*) for semantically rich operations: created, updated (with a diff), deleted, restored, force-deleted, and retrieved. - The database query listener (
DB::listen) for everything Eloquent events cannot see:sum()/avg()/min()/max(), mass query-builder updates/deletes, and rawDB::table()operations.
count() and exists() are deliberately never tracked — see
Tracked operations for why.
A correlation mechanism ensures a single logical operation — e.g.
$user->update([...]), which issues both an Eloquent updating/updated
pair and an UPDATE SQL statement — produces one activity, not several.
The same principle extends to the package's own internal reads: rendering
the dashboard itself is never mistaken for an application activity — see
Retrieval strategy & internal reads.
Why it exists
Most audit-log packages require you to add a trait to every model you want to track, or to manually call a logging method. That works, but it means:
- New models are silently untracked until someone remembers to add the trait.
- Bulk/raw operations that bypass model events are invisible.
- A naive "track every Eloquent retrieval" approach produces enormous noise — including from Laravel's own internals (see below).
This package instead observes the framework's own event system, so coverage is automatic and consistent across the whole application, today and for every model added in the future — while actively filtering out framework and package-internal noise rather than recording it blindly.
Installation
composer require abdulbaset/activity-tracker
Laravel's package auto-discovery registers ActivityTrackerServiceProvider
automatically. Then run the bundled migration:
php artisan migrate
Or use the convenience installer, which also offers to publish the config and migration for customization:
php artisan activity:install
Automatic activation
No code changes are required anywhere in your application. The package:
- Registers itself via Laravel's package discovery (
composer.json'sextra.laravel.providers). - Listens to the
eloquent.*wildcard event andQueryExecuted, both of which Laravel fires natively for every model and every query. - Ships its migration inside the package itself (loaded via
loadMigrationsFrom), sophp artisan migrateworks even if you never publish anything.
If you disable package discovery, register the provider manually:
// config/app.php 'providers' => [ Abdulbaset\ActivityTracker\ActivityTrackerServiceProvider::class, ],
Configuration
Publish the config file to customize behavior:
php artisan vendor:publish --tag=activity-tracker-config
This produces config/activity-tracker.php with every option documented
inline: the master enable switch, connection/table, which operations to
track, retrieval-tracking behavior, sensitive columns, ignore lists, query
logging, request-context capture, queue settings, and retention.
Database migration
The activities table stores:
| Column | Purpose |
|---|---|
batch_id / request_id |
Correlate activities from the same request/job |
causer_type / causer_id |
Polymorphic — who did it |
action |
created, updated, deleted, restored, force_deleted, retrieved, retrieved_many, sum/avg/min/max, bulk_updated, bulk_deleted, raw_insert, exception |
subject_type / subject_id |
Polymorphic — what it happened to |
old_values / new_values / changed_values |
JSON diffs for updates |
query / query_type / database_connection |
Captured SQL for query-listener-sourced activities |
result_count |
Row count, where derivable (see Limitations) |
duration_ms / memory_usage / memory_peak |
See Duration & performance |
ip_address, user_agent, route_name, http_method, url, path, referrer_url, http_status |
HTTP context, null outside HTTP — see Full URL, path & referrer |
execution_context |
http, cli, or queue |
command |
The Artisan command's signature name, in CLI context |
job_name, queue_name, queue_connection, queue_attempt |
Captured automatically when tracking happens inside a queued job — see Queue support |
exception_class, exception_message, exception_file, exception_line, stack_trace |
See Exception tracking |
auth_action, auth_guard, auth_provider, auth_identifier |
See Authentication event tracking — auth_identifier is always pre-masked |
broadcast_event, broadcast_channel, broadcast_channel_type, broadcast_status |
See Broadcast monitoring |
metadata |
Free-form JSON for anything else |
Every column added after the initial release is nullable — upgrading and
running the new migration never touches or invalidates existing rows; they
simply have null for metadata that didn't exist yet when they were
recorded.
Publish and customize it if needed:
php artisan vendor:publish --tag=activity-tracker-migrations
Tracked operations
| Operation | Source | Action recorded |
|---|---|---|
Model::create() |
Eloquent event | created (with the created attributes) |
$model->update() / save() |
Eloquent event | updated (with diff) |
$model->delete() |
Eloquent event | deleted (with the values at time of deletion) |
$model->restore() |
Eloquent event | restored (with its diff) |
$model->forceDelete() |
Eloquent event | force_deleted (with the values at time of deletion) |
Model::find() / first() / firstWhere() |
Eloquent event (buffered) | retrieved |
Model::get() / all() / cursor() |
Eloquent event (buffered) | retrieved_many |
sum() / avg() / min() / max() |
Query listener | sum / avg / min / max |
Model::where(...)->update([...]) |
Query listener | bulk_updated |
Model::where(...)->delete() |
Query listener | bulk_deleted |
DB::table(...)->insert()/update()/delete() |
Query listener | raw_insert / bulk_updated / bulk_deleted |
| Viewing a record's subject on the Activity Details page | Explicit, opt-in (logIntentionalView()) |
retrieved (tagged metadata.context = "ui") |
| An unhandled/reported exception | Exception handler decorator | exception — see Exception tracking |
| Login / failed login / logout / password reset / email verified / throttled | Laravel auth events | login / login_failed / logout / password_reset / email_verified / authentication_throttled — see Authentication event tracking |
| Authorization check denied | Gate::after() |
authorization_denied |
| A queued broadcast completing or failing | Queue lifecycle (BroadcastEvent job) |
broadcast — see Broadcast monitoring |
Toggle any of these independently under track in the config file.
count() and exists() are never tracked
Earlier versions tracked count() and exists() via the query listener.
They have been removed entirely — not hidden from the UI, not disabled
by default, genuinely removed from the tracking logic. Reasoning:
- Laravel's
QueryExecutedevent never exposes the actual count/boolean result (see Limitations), so these activities could only ever record "a count/exists query ran against this table", which carries essentially no audit value on its own. - In practice they were among the highest-volume, lowest-signal activities
the package produced — most applications run far more
count()/exists()calls than meaningful writes.
This is enforced as a hard rule in ActivityTrackerManager, independent of
configuration — there is no toggle that brings them back.
Retrieval strategy & internal reads
"Retrieved" tracking is the part of this package most likely to surprise you
if left unmanaged, because Eloquent fires a retrieved event for every
model hydration — including ones your application code never asked for.
Two exclusions keep it meaningful:
1. Laravel's own auth resolution is excluded by default — precisely, not
by model class. Every request through the auth middleware, every
Gate/can check, and every call to auth()->user() resolves the current
guard's user via a plain Eloquent query
(Illuminate\Auth\EloquentUserProvider::retrieveById(), or a custom
UserProvider). That specific call is a framework mechanic, not a
meaningful business read, and is excluded — but a direct User::find($id)
from your own application code (a profile page, an admin panel, anything)
is tracked exactly like any other model's retrieval, because it's a real
read.
The distinction is made by checking whether a
Illuminate\Contracts\Auth\UserProvider implementation is genuinely on the
call stack at the moment of the retrieval — not by checking the model's
class. An earlier version of this exclusion checked only the class, which
silently suppressed every retrieval of that class, including genuine
application reads — this was a real bug, fixed by moving the check to run
synchronously inside the actual Eloquent retrieved event (see
ActivityTrackerObserver::isAuthProviderResolution()), before the
retrieval is buffered, while the real call stack still exists to check:
'retrieval' => [ // ... 'exclude_auth_models' => true, // set false to audit login reads too ],
2. The dashboard's own reads are excluded. Every controller the package
ships wraps its internal queries — loading Activities for the table, a
subject/causer for display, statistics aggregates, broadcast/authentication
overview data — in TrackingContext::withoutTracking():
app(\Abdulbaset\ActivityTracker\Support\TrackingContext::class)->withoutTracking(function () { // Anything tracked inside here is suppressed, then automatically // restored afterward — even if the callback throws. $user = User::find($id); });
This is nestable, exception-safe, and the exact mechanism the package uses
internally to guarantee opening /activity-tracker never creates activity
noise for itself (see Admin dashboard). Use it in your
own code for any read that shouldn't count as a business event.
3. Intentional UI views are a separate, explicit mechanism — never inferred from Eloquent hydration. The Activity Details page calls:
app(\Abdulbaset\ActivityTracker\Contracts\ActivityLoggerInterface::class) ->logIntentionalView($subjectModel, ['via' => 'activity_details']);
exactly once per page view, recording a real retrieved activity tagged
metadata.context = "ui". This is deliberately decoupled from the automatic
listener (which is suppressed for that same read via withoutTracking()
above), so it can never duplicate it — and it is not subject to the
auth-model exclusion, because a deliberate "an admin viewed this record
through the audit UI" event is exactly the kind of thing worth auditing,
even for a model that would otherwise be filtered as auth noise. Toggle it
with retrieval.track_ui_views.
Honest limitation: a blind Eloquent retrieved event carries no
information about why the read happened. The two exclusions above cover
the overwhelmingly common sources of noise (framework auth resolution and
the package's own UI), but an application-level read that your own code
performs for a reason you don't consider meaningful (e.g. a middleware you
wrote, a policy check) will still be tracked like any other retrieval unless
you wrap it in withoutTracking() yourself. There is no reliable, generic
way to infer "was this read meaningful?" from the Eloquent event alone.
Retrieval tracking
Model::find(), first(), firstWhere(), and similar single-result calls
produce exactly one retrieved activity.
Model::get(), all(), or any call that hydrates a collection does not
produce one activity per row. Instead, retrievals are buffered for the
duration of the current request/console command/queue job and flushed as a
single retrieved_many activity with result_count set to the number of
models hydrated — whether that's 3 or 300,000.
'retrieval' => [ 'track_single' => true, 'track_many' => true, 'store_ids' => false, // opt-in: store the retrieved IDs in `metadata` 'max_ids' => 100, // cap even when store_ids is enabled ],
Storing IDs for large collections is opt-in and capped for exactly the reason the config comment says: memory and storage overhead.
Bulk operations
User::where('status', 'inactive')->update(['status' => 'active']); User::where('status', 'inactive')->delete();
Both bypass per-model Eloquent events entirely (this is standard Eloquent
behavior, not a tracker limitation). The query listener detects the
resulting UPDATE/DELETE SQL and records bulk_updated / bulk_deleted
against the table, without loading the affected models into memory.
Query tracking
A dedicated ActivityTrackerQueryClassifier normalizes SQL (case,
whitespace, quoting, identifier backticks) before classifying it as
select, insert, update, delete, count, exists, sum, avg,
min, max, or unknown. Plain select queries are not logged
directly — they're already represented by retrieved/retrieved_many, and
logging both would duplicate every read. count and exists are
classified (the information is genuinely there in the SQL shape) but are
never turned into an activity — see
Tracked operations.
The classifier is extensible without forking the package:
app(\Abdulbaset\ActivityTracker\Contracts\QueryClassifierInterface::class) ->extendPattern('/^explain/', 'diagnostic');
Duration & performance
Every automatically-tracked activity records duration_ms — a high-resolution
duration in milliseconds, measured as tightly as possible around the actual
tracked operation, never the whole HTTP request:
- Create/update/delete/restore/force-delete: measured with
hrtime(true)between the matching Eloquent pre-hook (creating/updating/deleting/restoring) and post-hook — essentially the underlying database write. - Aggregates, bulk updates/deletes, raw queries: use Laravel's own
QueryExecuted::$timedirectly — it's already a precise millisecond duration for that exact statement, so there's no need (or benefit) to hand-roll a second timer. retrieved/retrieved_many: intentionallynull. A buffered collection retrieval has no single meaningful duration to report — see Retrieval tracking.- Intentional UI views:
null— not a measured operation at all.
'performance' => [ 'enabled' => true, 'track_duration' => true, // memory_get_usage()/memory_get_peak_usage() add a small but real cost // to every tracked operation — off by default. 'track_memory' => false, 'slow_ms' => 100, 'very_slow_ms' => 1000, ],
slow_ms/very_slow_ms only drive the dashboard's Fast/Normal/Slow/Very
Slow classification (Abdulbaset\ActivityTracker\Support\DurationFormatter)
and the "Slow activities only" filter — they never change what gets
tracked. The dashboard formats durations intelligently (0.42 ms,
845.20 ms, 1.42 s) rather than showing raw floats.
This package is an audit/observability tool, not a profiler — duration and (optional) memory figures exist to flag genuinely slow operations, not to replace Blackfire, Telescope, or a real APM.
Full URL, path & referrer
The full request URL — not the route name — is the primary "where did this happen" fact, because a route can be renamed, unnamed, or a closure, while the URL is simply what actually happened:
url: https://example.com/admin/users/15?tab=permissions
path: admin/users/15
route_name: admin.users.show (secondary metadata, still recorded)
The HTTP Referer header (yes, misspelled in the HTTP spec itself) is
captured as referrer_url when present, and left null — never
fabricated — when absent. Both url and referrer_url:
- have configured sensitive query parameters redacted before storage
(
token,password,api_key,secret,access_token,refresh_token,client_secret,signature, by default — extend viasensitive_query_parameters):?token=abc123becomes?token=[REDACTED], preserving the rest of the URL; - are truncated at a configurable length (
context.max_url_length,context.max_referrer_length— both default 2048) since both are untrusted, attacker-influenceable input; - are escaped on output everywhere the dashboard renders them (standard
Blade
{{ }}escaping) — never rendered as raw HTML, never executed.
HTTP status code
Most activities are recorded mid-request, before a response — and
therefore a status code — exists yet. http_status is backfilled once,
after the response is actually sent, by
ActivityTrackerRequestLifecycleMiddleware::terminate() (pushed onto
Laravel's global middleware stack, so it covers API-only routes too, not
just the "web" group): a single UPDATE ... WHERE request_id = ? per
request, skipped entirely for requests that tracked nothing at all.
Exceptions that carry their own status code (e.g. a thrown HttpException)
report it immediately; everything else gets the real response status a
moment later.
Exception tracking
Unhandled/reported exceptions are recorded as a dedicated exception
activity — never disguised as a CRUD action, always visually distinct in
the dashboard (a dedicated red badge, its own section on the details page).
How it hooks into Laravel
ActivityTrackerExceptionHandlerDecorator wraps (via
Container::extend()) whatever Illuminate\Contracts\Debug\ExceptionHandler
is already bound — your own custom Handler, or Laravel's default. It is
never a replacement:
report(),shouldReport(),render(), andrenderForConsole()are all forwarded to the original handler, unchanged — your own customrender()/register()logic keeps working exactly as before.- Recording an exception is wrapped in its own
try/catchinsideActivityTrackerExceptionService: if building or storing the activity fails for any reason, the original exception still reaches$handler->report($e)normally. A tracker failure can never replace or suppress your application's real error handling. - The same exception instance is never recorded twice — deduplicated by
object identity (
spl_object_id()), not by message or trace content (which two unrelated exceptions could share).
What's captured
exception_class, exception_message, exception_file, exception_line,
and (configurable) stack_trace, plus the same request context every other
activity gets (url, execution_context, request_id, causer, ...) and an
http_status derived immediately when the exception itself carries one
(Symfony\...\HttpExceptionInterface), or backfilled like any other
activity otherwise.
Ignored ("expected") exceptions
Without a filter, every failed login attempt and unmatched route would
create an exception activity — the same noise problem that led to removing
count()/exists() tracking. These are excluded by default:
'exceptions' => [ 'enabled' => true, 'store_trace' => true, 'max_trace_length' => 10000, 'ignored_exceptions' => [ \Illuminate\Validation\ValidationException::class, \Illuminate\Auth\AuthenticationException::class, \Illuminate\Auth\Access\AuthorizationException::class, \Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class, \Illuminate\Database\Eloquent\ModelNotFoundException::class, \Illuminate\Http\Exceptions\ThrottleRequestsException::class, ], ],
Add or remove classes freely (subclasses are matched via instanceof).
Stack trace security — read this before enabling in production
store_trace is true by default and getTraceAsString() output is
truncated to max_trace_length (10,000 characters) — but PHP's default
stack trace formatting can include literal scalar arguments passed to
functions in the call chain. If a plain-text password or token was ever
passed as a bare string argument somewhere in that call chain, it can show
up in the trace. This is standard PHP/Laravel behavior, not something a
single trace string can be selectively redacted from after the fact.
For high-sensitivity applications, set 'store_trace' => false — the
class/message/file/line are still fully captured either way.
Authentication event tracking
Login/logout/account-security events are observed via Laravel's own
authentication events — never overriding auth behavior, and a tracking
failure can never break a real login/logout (every handler in
ActivityTrackerAuthenticationTracker is wrapped in its own try/catch).
| Event | Action | Source |
|---|---|---|
| Successful login | login |
Illuminate\Auth\Events\Login |
| Failed attempt | login_failed |
Illuminate\Auth\Events\Failed |
| Logout | logout |
Illuminate\Auth\Events\Logout |
| Session/token re-authentication | authenticated |
Illuminate\Auth\Events\Authenticated — off by default, see below |
| Password reset completed | password_reset |
Illuminate\Auth\Events\PasswordReset |
| Email verified | email_verified |
Illuminate\Auth\Events\Verified |
| Too many attempts | authentication_throttled |
Illuminate\Auth\Events\Lockout |
| Authorization check denied | authorization_denied |
Gate::after() |
Each works across guards — auth_guard/auth_provider are captured from
the event itself (or resolved from auth.guards.{guard}.provider), never
assuming the default "web" guard.
Why authenticated defaults to off
Authenticated fires on essentially every authenticated request —
session/token resolution, not an actual login action — enabling it by
default would reproduce the exact "retrieved User" noise problem this
package already fixed once (see
Retrieval strategy & internal reads).
Turn it on deliberately if you want that level of detail:
'authentication' => ['track' => ['authenticated' => true]],
What's intentionally NOT implemented
Only events Laravel's core authentication system reliably fires are implemented — nothing is faked:
password_changed— a password change is just aUserupdate; it's already covered (with the password itself already stripped) by ordinary CRUD tracking. There's no separate core Laravel event for it.password_reset_requested— core Laravel'sPassword::sendResetLink()dispatches no event to hook.account_locked/account_unlocked— core Laravel has no concept of a permanent account lock, only the temporary throttlingLockoutrepresents (authentication_throttled). Claiming a permanent lock from a temporary throttle would be misleading, so it isn't done.
Authorization denials
Registered via Gate::after() — Laravel's own documented mechanism for
observing every authorization check's outcome without altering it. Only
denials are recorded (an allowed check is not a security-relevant
signal); the ability name is stored in metadata.ability, and the checked
subject (if a model) becomes the activity's subject_type/subject_id.
Security
The submitted password is never read, logged, or stored — not even
masked. On a failed attempt, only the configured identifier field
(authentication.identifier_field, default email) is extracted, and only
that exact field — there is deliberately no "fall back to the first
credential" behavior, because that array also contains the plaintext
password. The identifier is always masked before storage:
ahmed@example.com -> a***@example.com
ahmed123 -> a***3
Broadcast monitoring
Monitors Laravel Broadcasting — WebSocket channels and their connected clients — not Notification Channels (mail/database/Slack notification delivery). Two independent things are provided:
-
Broadcast activity tracking — always available, driver-agnostic: observes
Illuminate\Broadcasting\BroadcastEvent(the queued job Laravel creates for anyShouldBroadcastevent) completing or failing, via the existing queue lifecycle hooks. Recorded as abroadcastactivity per channel, withbroadcast_event,broadcast_channel,broadcast_channel_type,broadcast_status(sent/failed), andduration_ms. -
Live channel/connection statistics — only available when the configured broadcasting driver exposes a management API. Currently integrated: Pusher, and Laravel Reverb (which implements the Pusher HTTP protocol), and only when the optional
pusher/pusher-php-serverpackage is installed. Every other driver (redis,log,null,ably, or Pusher/Reverb without the SDK) reports honestly:Live connection statistics unavailable for the configured broadcasting driver (redis).Connection counts are never fabricated. A channel with an unknown connection count shows
—, never0— those mean different things.
"Sent" is not "received"
broadcast_status = sent means the queued broadcast job completed without
throwing — the application/provider accepted the operation. It does
not mean any connected browser actually received or rendered it.
Laravel has no built-in client-acknowledgement mechanism to observe that,
and this package does not pretend otherwise.
ShouldBroadcastNow is not tracked
Only queued (ShouldBroadcast) events are observed, via the queue job
Laravel wraps them in. ShouldBroadcastNow events broadcast synchronously
with no queue-job hook to observe non-invasively, so they are not
tracked in this release — a documented limitation, not a silent gap.
Presence channels
Presence channel members (when the provider integration supports it) are
available on the channel detail page — user_id and display name only,
never credentials, tokens, or session data. Disable member visibility
independently of the rest of the dashboard:
'broadcast_monitoring' => ['show_presence_members' => false],
Configuration
'broadcast_monitoring' => [ 'enabled' => true, 'monitor_connections' => true, 'show_presence_members' => true, 'auto_refresh' => true, 'refresh_interval' => 10000, // ms — deliberately not aggressive; this polls a third-party API 'cache_seconds' => 5, // how long a provider response is cached — see below ],
Defined channels vs. active provider channels
Two genuinely different concepts, never conflated:
- Defined channels — the channel patterns your application registered
via
Broadcast::channel('orders.{orderId}', ...). A definition existing says nothing about whether any client is currently connected to a channel matching it. Shown as a best-effort list (Laravel exposes no public API for this, so it's read via reflection and simply omitted, never an error, if that ever fails). - Active provider channels — channels the broadcasting provider itself
currently reports as live (
channels()above). This is what the overview stats (Known/Active/Connections/Presence counts) are based on.
Provider calls are cached and never made from ordinary activity tracking
The provider's management API is called only from the Broadcast
Monitoring pages themselves (dashboard render, manual refresh, an
auto-refresh tick, a channel detail page) — never as a side effect of
ordinary CRUD/exception/authentication tracking, and never once per
Activity row. Within a single request, the channel list is fetched at most
once and reused for every statistic that needs it (known/active/
connections/presence counts all share one call). Across requests,
broadcast_monitoring.cache_seconds (default 5) caches the provider's
response so concurrent dashboard viewers and closely-spaced auto-refresh
ticks don't each trigger their own API call. Set it to 0 to always call
the provider live.
Provider outages never break your application
Every call to the broadcasting provider's management API
(ActivityTrackerBroadcastChannelMonitor's Pusher/Reverb adapter) is
wrapped — an outage, timeout, or credential problem degrades to
"unavailable", never an exception that reaches your users.
Sensitive data protection
Configured sensitive_columns (password, tokens, secrets, etc.) are
excluded entirely from old_values/new_values/changed_values — never
masked, never stored in any form:
'sensitive_columns' => [ 'password', 'password_confirmation', 'remember_token', 'api_token', 'access_token', 'refresh_token', 'secret', ],
Raw SQL bindings are not stored by default (query_log.store_bindings = false) precisely because bindings have no reliable column names to match
against sensitive_columns. If you opt in, a best-effort heuristic redacts
long, token-shaped values — see Limitations.
Ignoring models
'ignored_models' => [ App\Models\TemporaryLog::class, ], 'ignored_tables' => [ 'migrations', 'jobs', 'sessions', // ...and more by default ],
The package's own activities table (and Activity model) is always
excluded — this is what prevents infinite recursion, on top of the explicit
TrackingContext::withoutTracking() guard used when writing activity rows.
Authentication / causer tracking
The package never assumes App\Models\User is your authenticatable model.
It asks Laravel's auth factory for whatever guard is active and stores a
polymorphic reference:
$activity->causer_type; // e.g. App\Models\Admin, App\Models\User, null $activity->causer_id; $activity->causer; // resolved via morphTo()
Outside of an authenticated context (guests, CLI, queue jobs with no auth
session), both fields are null.
Request metadata
When running inside an HTTP request, activities capture ip_address,
user_agent, route_name, http_method, and url (each independently
toggleable under context in the config). None of this is required —
every accessor degrades to null in CLI, Artisan commands, and queue jobs,
which the package is fully functional in.
Queue support
Enable asynchronous storage so tracking never adds latency to the request that triggered it:
'queue' => [ 'enabled' => true, 'connection' => null, // defaults to your app's default queue connection 'queue' => 'default', ],
The queued job payload is a plain, JSON-safe array (IDs and scalars) — never a serialized Eloquent model — so it never depends on the original request or database state still being current by the time a worker picks it up.
Long-running workers are handled explicitly: TrackingContext (which holds
the current batch ID, request ID, and buffered retrievals) is reset on every
JobProcessing event and flushed + reset again on JobProcessed, so nothing
leaks from one job to the next in the same worker process.
Job context
When any tracked operation happens while a queued job is processing,
execution_context is "queue" and these columns are captured directly
from the job Laravel handed to JobProcessing:
job_name: App\Jobs\SyncUserOrders (the queued job's class)
queue_name: default
queue_connection: redis
queue_attempt: 1
Like everything else, this is reset between jobs — no job's context can leak into the next job's activities in the same worker process.
Note: the sync queue connection (the Laravel default when nothing else is
configured) never fires JobProcessing/JobProcessed at all — jobs run
inline without going through a worker — so execution_context for a
sync-dispatched job's tracked operations will be whatever it already was
(typically "http", since sync jobs usually run mid-request). This is a
Laravel behavior, not a package limitation.
Transactions
DB::transaction(function () use ($user) { $user->update(['status' => 'active']); });
If synchronous storage is used and the transaction commits, the activity is
already written by the time the transaction completes. If the transaction
rolls back, the model's updated Eloquent event never actually fires
for a failed UPDATE in the first place in most rollback scenarios — but if
your queue mode is enabled, a job could still be dispatched before a
rollback occurs. To guarantee an activity is never recorded for work that
gets rolled back, dispatch the queued job only after the transaction commits
by leaving queue.enabled off for transactional code paths, or by
wrapping the operation and manually deferring via DB::afterCommit() in
your own application code. This trade-off is documented rather than hidden:
implementing fully transaction-aware buffering for every possible queue
configuration was judged more complex than the benefit justified for a v1.
Reading activities
use Abdulbaset\ActivityTracker\Models\Activity; Activity::query() ->where('action', 'updated') ->latest() ->get(); Activity::causedBy($user) ->forSubject($post) ->whereAction('updated') ->latest() ->get(); Activity::inBatch($batchId)->get();
None of the automatic tracking depends on this API — it exists purely for querying what has already been recorded.
Admin dashboard
A complete, optional Blade-based admin dashboard ships with the package — dashboard overview, a searchable/filterable/sortable activities table, and a detailed per-activity view. No React/Vue/Inertia/Livewire, no Node build step, no separate publish step required to get working, styled pages.
/activity-tracker -> dashboard overview
/activity-tracker/activities -> searchable, filterable, sortable index
/activity-tracker/activities/1 -> full detail view for one activity
/activity-tracker/statistics -> breakdowns and a lightweight time-series chart
Enabling / disabling
The dashboard is on by default. Turn it off entirely — no routes are registered at all, not merely hidden behind a 403 — with:
// config/activity-tracker.php 'ui' => [ 'enabled' => false, ],
Routes
Every route is named, under the activity-tracker. prefix:
| Name | Purpose |
|---|---|
activity-tracker.dashboard |
Overview page |
activity-tracker.activities.index |
Searchable/filterable activities table |
activity-tracker.activities.show |
Single activity detail |
activity-tracker.statistics |
Breakdown + chart page |
activity-tracker.authentication |
Login/logout/security overview |
activity-tracker.broadcasts |
Broadcast Monitoring overview |
activity-tracker.broadcasts.channel |
A single channel's detail page |
activity-tracker.assets |
Serves the dashboard's own CSS/JS |
Never hardcode the dashboard's URL — always use route('activity-tracker.activities.index')
and friends, so a custom ui.prefix doesn't break your links.
Authorization
The dashboard is never exposed just because a user is logged in. Access is
gated by a Laravel Gate named viewActivityTracker, checked via the can:
middleware whenever ui.authorize is true (the default).
The package ships a safe, closed-by-default fallback: the bundled Gate
only allows access when app()->environment('local') — i.e. it works out of
the box on your local machine and denies everyone once deployed, until you
explicitly decide otherwise. This mirrors how Laravel Telescope and Horizon
behave, and deliberately does not assume a User model, a role column,
or an isAdmin() method exists anywhere in your application.
To control access yourself, define the Gate in your own AuthServiceProvider
(this overrides the package's default because the package boots first):
use Illuminate\Support\Facades\Gate; Gate::define('viewActivityTracker', function ($user) { return $user->isAdmin(); // or ->hasRole('admin'), ->can('view-audit-log'), etc. });
To drop the Gate check entirely (e.g. you already fully control access via
ui.middleware), set:
'ui' => [ 'authorize' => false, ],
ui.middleware (default ['web', 'auth']) is applied to every dashboard
route except the static asset route, which intentionally has no auth
requirement — it serves only CSS/JS, nothing user-specific or sensitive.
Configuration
'ui' => [ 'enabled' => true, 'prefix' => 'activity-tracker', 'middleware' => ['web', 'auth'], 'authorize' => true, 'per_page' => 25, 'per_page_options' => [25, 50, 100, 250], 'theme' => 'system', // 'light', 'dark', or 'system' ],
Search, filters, sorting, pagination
The activities index supports a single search box (description, action,
subject type/ID, causer ID, IP, route, request ID, batch ID), an expandable
filter panel (action multi-select, subject type, causer, date range, IP,
HTTP method, route, request ID, batch ID) with a live "N active" indicator,
and column sorting — including by id, created_at, action,
subject_type, subject_id, and causer. Sortable columns and per-page
sizes are whitelisted server-side
(Abdulbaset\ActivityTracker\Services\ActivityTrackerFilters) — raw
query-string values never reach orderBy() directly; an unrecognized sort
value silently falls back to created_at. Filters persist across
pagination automatically since they're plain query-string parameters.
The activity detail page links directly into batch/request-scoped views of
this same index (?batch_id=... / ?request_id=...) so you can see
everything that happened in one HTTP request or one correlated operation.
AJAX behavior
The activities index loads and updates via XMLHttpRequest — search,
every filter, sorting, pagination, and the per-page selector all update the
table in place, with no full page reload:
- Search is debounced (400ms) while typing; pressing Enter or clicking "Search" submits immediately.
- Requests are sequenced, not just fired — starting a new request aborts any request still in flight, so a fast typist can never have an older response overwrite a newer one.
- The URL reflects filter state via
history.pushState()(filter/search changes) orhistory.replaceState()(pagination, sorting — kept out of back-button history to avoid clutter), so the browser Back/Forward buttons and copy/pasting the URL both work, andpopstatere-fetches the correct page without pushing a duplicate history entry. - Loading state is a small spinner badge over the (dimmed, still visible) existing table — never a full-screen loader — and the previous results stay on screen until the new ones are ready.
- Errors replace the results area with "Unable to load activities. Please try again." and a Retry button; no raw exception ever reaches the browser.
- Graceful fallback: every control is a real
<a>/<form>with a realhref/actionfirst. JavaScript intercepts the click/submit for the AJAX behavior above; with JavaScript disabled, every one of these still works as an ordinary server-rendered page navigation to the same named route.
The endpoint is the same named route as the page itself
(activity-tracker.activities.index) — Laravel's $request->ajax()
(driven by the X-Requested-With: XMLHttpRequest header the browser sets
automatically for XMLHttpRequest) is what selects the JSON response:
{
"success": true,
"data": {
"html": "<div class=\"at-table-wrap\">...</div>",
"total": 8421,
"hasActiveFilters": true
}
}
No URL is ever hardcoded in JavaScript — the results container carries the
index route's URL in a data-at-index-url attribute, and every link the JS
intercepts already has its own real href generated by route(...) in
Blade.
Customizing views
php artisan vendor:publish --tag=activity-tracker-views
Publishes to resources/views/vendor/activity-tracker/. Anything there
overrides the package's own view of the same relative path — standard
Laravel package view override behavior.
Customizing assets
php artisan vendor:publish --tag=activity-tracker-assets
Publishes to public/vendor/activity-tracker/{css,js}/app.css|app.js. If a
published copy exists, the dashboard's asset route (activity-tracker.assets)
serves that instead of the package's bundled copy — edit it freely.
Dark mode
A theme toggle in the top bar switches between light and dark, persisted in
localStorage (no database setting involved). ui.theme controls the
initial preference for first-time visitors: 'light', 'dark', or
'system' (follows the OS/browser preference via prefers-color-scheme).
Animations (row refresh, filter panel open/close, toasts) are subtle and
CSS-transition-based, and are minimized automatically when the visitor's OS
has prefers-reduced-motion: reduce set — functionality is unaffected.
JavaScript and CSS isolation
All package JavaScript lives under a single global, window.ActivityTracker
— nothing else is added to window. All package CSS classes are prefixed
.at- (.at-card, .at-table, .at-btn, ...) and every page is wrapped in
a single .at-scope container, so the dashboard cannot collide with or
override your host application's own Bootstrap, Tailwind, or hand-rolled
.card/.table/.button/.modal classes elsewhere on the same domain.
Class naming conventions
Every package-specific class is named so it's identifiable at a glance in a stack trace or log line — not because of any real PHP namespace collision risk (there isn't one), but because "ActivityController" or "ActivityService" read as generic enough to belong to almost any application:
| Role | Class |
|---|---|
| Central tracking decision-maker | Services\ActivityTrackerManager |
| Activities index/detail controller | Http\Controllers\ActivityTrackerActivityController |
| Dashboard overview controller | Http\Controllers\ActivityTrackerDashboardController |
| Statistics page controller | Http\Controllers\ActivityTrackerStatisticsController |
| Dashboard CSS/JS controller | Http\Controllers\ActivityTrackerAssetController |
| Search/filter/sort/pagination | Services\ActivityTrackerFilters |
| Dashboard aggregate queries | Services\ActivityTrackerStatisticsService |
| Activity storage backend | Services\ActivityTrackerRepository |
| SQL classification | Services\ActivityTrackerQueryClassifier |
Eloquent eloquent.* listener |
Observers\ActivityTrackerObserver |
QueryExecuted listener |
Listeners\ActivityTrackerQueryListener |
| Retrieval buffer → activity flush | Services\ActivityTrackerRetrievalFlusher |
| Exception handler decorator | Handling\ActivityTrackerExceptionHandlerDecorator |
| Exception recording policy (ignore list, dedup) | Services\ActivityTrackerExceptionService |
Auth event listener (Login, Failed, Gate::after(), ...) |
Listeners\ActivityTrackerAuthenticationTracker |
| Broadcast job observer | Listeners\ActivityTrackerBroadcastTracker |
| Broadcast dashboard controller | Http\Controllers\ActivityTrackerBroadcastController |
| Authentication dashboard controller | Http\Controllers\ActivityTrackerAuthenticationController |
| Live channel/connection stats (per-provider) | Contracts\BroadcastChannelMonitorInterface, Services\Broadcasting\PusherBroadcastChannelMonitor, Services\Broadcasting\NullBroadcastChannelMonitor |
| Broadcast dashboard aggregate queries | Services\ActivityTrackerBroadcastStatisticsService |
A few classes deliberately kept their shorter names, on the judgment that prefixing them would add noise without adding clarity:
Models\Activity— this is the package's public API (Activity::query()->..., documented extensively above); renaming it would be a breaking change for no real disambiguation benefit inside a package literally about "activity" tracking.Support\TrackingContext,CauserResolver,RequestContextResolver,Services\SensitiveDataSanitizer,Services\ActivityTransformer— narrowly-scoped internal collaborators, already unambiguous in context, never referenced directly by application code.- Contracts (
ActivityLoggerInterface,QueryClassifierInterface, ...), Events, the queueJobs\StoreActivityjob, andConsolecommands — already clear from their namespace and behavior-based names.
Route names (activity-tracker.*), the view namespace
(activity-tracker::...), and the config key (activity-tracker) were
already consistent before this pass and remain so.
Events
use Abdulbaset\ActivityTracker\Events\ActivityRecording; // before persistence, payload is mutable use Abdulbaset\ActivityTracker\Events\ActivityRecorded; // after persistence
Event::listen(ActivityRecording::class, function (ActivityRecording $event) { $event->payload['metadata']['tenant_id'] = tenant()->id; });
Extending the package
Every major component is bound against an interface and can be swapped via the container:
$this->app->bind( \Abdulbaset\ActivityTracker\Contracts\ActivityStorageInterface::class, \App\Support\ElasticsearchActivityStorage::class, );
Available contracts: ActivityLoggerInterface, QueryClassifierInterface,
ActivityTransformerInterface, SensitiveDataSanitizerInterface,
ActivityStorageInterface.
Performance considerations
- Plain
SELECTqueries are never logged individually — only the aggregated retrieval count is recorded, once per request/job. - Collection retrieval is O(1) activity records regardless of row count.
- Config is merged once at boot and read through Laravel's config repository
(already in-memory/cached in production via
config:cache). - Bulk operations never load the affected models into memory.
- Async storage (
queue.enabled) removes the write from the request's critical path entirely.
Limitations
Please read this section before relying on the package for compliance-grade auditing:
result_countfor query-listener-sourced activities is not always available. Laravel'sQueryExecutedevent exposes SQL, bindings, and timing — not the query's return value or affected-row count.sum/avg/min/max,bulk_updated, andbulk_deletedactivities record that the operation happened and against which table, but the numeric result itself is not captured (this is also whycount()andexists()were removed entirely rather than tracked with a perpetually-null result — see Tracked operations).retrieved/retrieved_manyare the exception — theirresult_countis fully accurate because it comes from Eloquent's own hydration, not the query listener.- A blind Eloquent
retrievedevent carries no information about intent. The package excludes the two overwhelmingly common sources of false-signal noise (Laravel's own auth resolution, and the package's own dashboard reads) — see Retrieval strategy & internal reads — but cannot generically know that some other read your own application code performs isn't meaningful to you. Wrap it inTrackingContext::withoutTracking()yourself if so. - Raw/table-only queries cannot be mapped to an Eloquent model class.
DB::table('users')->sum('balance')producesmodel_type = null, table = usersby design — the package will not guess which model, if any, represents a table. - A query-builder mass update/delete cannot be distinguished from an
equivalent raw
DB::table()call. Both produce identical SQL. Both are recorded asbulk_updated/bulk_deletedagainst the table. - Bindings are not stored by default, and when enabled, sensitive-value
redaction in bindings is a best-effort heuristic (long, whitespace-free
strings), not a guarantee — there is no reliable column name to check
against
sensitive_columnsat the binding level. - Transactional rollback is not fully guaranteed to prevent a queued activity write — see Transactions.
- The dashboard cannot display what the engine didn't capture. The activity detail page shows "Not captured" for aggregate results and affected-row counts rather than a fabricated number, for the reasons above. It also never guesses a link to your application's own model-show route — if you want that, add it in a published, customized view.
http_statusbackfill is best-effort, not instantaneous. It updates after the response is sent, scoped byrequest_id, via a terminable middleware. A process that terminates abnormally (a fatal error the handler never sees,exit()called mid-request, a killed worker) can leavehttp_statusnullfor activities from that request — this is strictly better than guessing a status that was never actually reached.- Stack traces can contain scalar arguments from the call chain — see
Exception tracking § Stack trace security before
enabling
store_tracefor a high-sensitivity application.
Troubleshooting
No activities are being recorded at all.
Check activity-tracker.enabled and that the activities table exists
(php artisan migrate). Also confirm the model/table isn't in
ignored_models / ignored_tables.
I see duplicate-looking activities for one save() call.
This should not happen — please open an issue with the model's traits and
the exact call, as it likely indicates a gap in the creating/updating/
deleting expectation correlation.
Activities aren't queued even though queue.enabled is true.
Confirm a queue worker is running (php artisan queue:work) and that
queue.connection resolves to a valid, running connection.
I get a 403 visiting the dashboard.
Expected outside the local environment until you define your own
viewActivityTracker Gate — see Authorization.
I get redirected to a login route I don't have, instead of a 403.
That's Laravel's auth middleware (in ui.middleware), not this package —
it redirects guests to your login route. Log in first, or adjust
ui.middleware if you're gating access another way.
The dashboard is unstyled / plain HTML.
Check that the activity-tracker.assets route is reachable (it's excluded
from ui.middleware's auth requirement on purpose) and that nothing in your
app is intercepting /activity-tracker/assets/*.
Testing
composer install composer test # PHPUnit via Orchestra Testbench composer stan # PHPStan composer format # Laravel Pint
The test suite covers: create/update/delete/restore/force-delete, find/
first/get, aggregates, bulk update/delete, raw DB::table() operations,
ignored models, sensitive-field exclusion, recursion safety, and retrieval
buffering/flushing; explicit regressions proving count()/exists() create
zero activities even if force-enabled via config; the auth-model exclusion
(and that it can be disabled), TrackingContext::withoutTracking()'s
nesting and exception-safety, and the intentional-UI-view mechanism recording exactly
once; duration recording (present, numeric, positive, disableable, and
correctly absent for retrieved/retrieved_many); full URL/query-string/path/
route capture over real HTTP requests, sensitive-query-parameter redaction
on both url and referrer_url, referrer truncation, and http_status
backfill after the response is sent; and the exception subsystem —
class/message/file/line/trace capture, trace truncation and disabling, the
default ignored-exception list (and that it's configurable), object-identity
deduplication of a re-reported exception, status-code derivation from
HttpExceptionInterface, and that disabling exception tracking never stops
the original handler from still running.
Also covered: the intentional-UI-view mechanism recording exactly
once with no duplicate from the suppressed automatic listener; job/queue
context capture (job_name/queue_name/queue_connection/queue_attempt)
and that it never leaks between two different jobs in the same worker;
authentication tracking — login (with guard/causer/duration), failed
login (masked identifier, and that the submitted password never appears
anywhere in the stored row, not even in metadata), logout (causer captured
before the guard forgets it), that authenticated is off by default and
can be enabled, password reset, email verification, throttling (masked
identifier), authorization denial via Gate::after() (and that an
allowed check is never recorded), and that the whole subsystem can be
disabled; and broadcast monitoring — the default NullBroadcastChannelMonitor
honestly reporting unavailability (never a fabricated 0), a processed
BroadcastEvent job producing one activity per channel with the correct
channel type, a failed job recording the exception, duration measurement,
non-broadcast jobs being ignored entirely, the feature being disableable,
and a corrupt/unreadable job payload never propagating an exception into
the queue worker — plus, for the dashboard:
the dashboard: access control (default Gate, custom Gate, authorize
toggle), the UI being fully removable (ui.enabled = false deregisters its
routes), search, every filter, ID/column sorting with a malicious-input
whitelist test, pagination, the AJAX JSON endpoint, batch/request-scoped
views, graceful handling of a deleted subject or causer on the detail page,
and end-to-end proof that visiting every dashboard page creates no activity
beyond the one deliberate subject view.
Contributing
Issues and pull requests are welcome. Please run composer stan and
composer format before submitting, and add tests for new behavior.
License
MIT. See LICENSE.