Activity log & audit trail for Laravel. Auto-log every Eloquent model change — with field-level diffs, IP capture, user attribution, batch grouping, data masking, and a full REST API — in one composer require.
Tested on Laravel 12 and 13; the constraints also allow 10 and 11 (see version matrix). A free, self-hosted dashboard is available as a separate repository.
Most Laravel audit packages store a flat JSON blob and call it done. laravel-audit goes further:
- Field-level diffs — exactly which columns changed, from what value, to what value
- Sensitive-field masking —
password,api_key,card_number, and any custom field are stored as[REDACTED]automatically - Batch grouping — wrap multiple operations in
AuditLog::batch()and every entry shares abatch_idso you can replay a full transaction - Alert rules — define threshold rules (e.g. "more than 10 deletes per minute") that fire Mailgun emails or Slack webhooks
- Retention pruning — auto-scheduled daily cleanup via
audit:prune; configurable retention window - REST API — nine endpoints covering feed, timeline, stats, export (CSV + PDF), and alert rule management, ready to power any dashboard or SIEM integration
| Dependency | Version |
|---|---|
| PHP | ^8.1 |
| Laravel | ^10.0 | ^11.0 | ^12.0 | ^13.0 |
league/csv |
^9.15 |
dompdf/dompdf |
^2.0 | ^3.0 |
guzzlehttp/guzzle |
^7.8 |
Laravel's own PHP floor moves between majors, so Composer resolves the right pair for whatever you are running.
| Laravel | PHP | Status |
|---|---|---|
| 13.x | 8.3+ | Tested in CI |
| 12.x | 8.2+ | Tested in CI |
| 11.x | 8.2+ | Allowed, not tested — see below |
| 10.x | 8.1+ | Allowed, not tested — see below |
Laravel 10 and 11 are past end of life. Every remaining release of both carries an unpatched security advisory, and Composer blocks advisory-affected packages by default — so those frameworks cannot be installed at all under a default setup, regardless of this package.
The constraints still permit them, because the code is compatible and nobody knowingly maintaining a legacy app should be blocked by us. They are simply not versions this package can test or recommend. If you are on either, upgrading the framework is the more urgent job.
composer require dineshstack/laravel-audit
php artisan audit:installaudit:install publishes config/audit.php, publishes and optionally runs the migration, and appends the required environment variable stubs to your .env file.
If you prefer to publish assets manually:
php artisan vendor:publish --tag=audit-config
php artisan vendor:publish --tag=audit-migrations
php artisan migrateAdd the LogsActivity trait to the models you want to audit:
use Dineshstack\LaravelAudit\Traits\LogsActivity;
class Invoice extends Model
{
use LogsActivity;
}That's it. Every create, update, and delete on Invoice is now logged with the changed attributes, old and new values, the authenticated user, their IP address, user agent, and the full request URL.
Use the fluent builder for events that aren't model-driven:
use Dineshstack\LaravelAudit\Facades\AuditLog;
AuditLog::log('payment.processed')
->on($invoice)
->with(['amount' => 500, 'currency' => 'USD'])
->by($user)
->description('Stripe charge succeeded')
->save();All methods except log() and save() are optional:
| Method | Description |
|---|---|
->on(Model $subject) |
The model being acted upon |
->with(array $data) |
Arbitrary context stored in properties |
->by(?Model $causer) |
Who did it — defaults to auth()->user() |
->description(string $desc) |
Human-readable label stored alongside the event key |
Wrap multiple operations in AuditLog::batch() to link them under a shared batch_id. This lets you query or replay an entire multi-step transaction as a unit:
AuditLog::batch(function () use ($order) {
$order->update(['status' => 'shipped']);
$order->items()->each->update(['dispatched' => true]);
});All entries created inside the closure share the same UUID batch_id.
When a model is updated, the package computes a precise diff of only the changed fields:
{
"old": { "status": "pending", "total": 450 },
"new": { "status": "shipped", "total": 500 },
"diff": {
"status": { "old": "pending", "new": "shipped", "type": "changed" },
"total": { "old": 450, "new": 500, "type": "changed" }
}
}Unchanged fields are never stored. Added fields report "type": "added" with old: null; removed fields report "type": "removed" with new: null.
Override these two methods in your model:
class User extends Model
{
use LogsActivity;
// Never log these columns
protected function getAuditExclude(): array
{
return ['updated_at', 'created_at', 'remember_token', 'two_factor_secret'];
}
// Log ONLY these columns (overrides exclude; empty = log all minus excluded)
protected function getAuditInclude(): array
{
return ['name', 'email', 'role'];
}
}Any field whose name contains a masked keyword is stored as [REDACTED] in both the old/new snapshots and the diff. The default masked keywords are:
password, token, secret, api_key, card_number, cvv, ssn, remember_token
Customize via .env (comma-separated, case-insensitive substring matching):
AUDIT_MASKED_FIELDS=password,token,secret,api_key,card_number,pin,ssnOr publish and edit config/audit.php directly.
All endpoints are prefixed with /api/audit and optionally protected by a token set in AUDIT_TOKEN. Pass the token as either:
X-Audit-Token: your_tokenheaderAuthorization: Bearer your_tokenheader
If AUDIT_TOKEN is empty, all endpoints are unauthenticated (suitable for internal networks).
Paginated activity feed with filtering.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
event |
string | Filter by event name (e.g. update, payment.processed) |
causer_id |
integer | Filter by user ID |
subject_type |
string | Filter by model class (e.g. App\Models\Invoice) |
ip_address |
string | Filter by IP address |
date_from |
date | Filter entries on or after this date |
date_to |
date | Filter entries on or before this date |
search |
string | Full-text search across description and properties |
batch_id |
string | Show all entries in a specific batch |
per_page |
integer | Results per page (default: 30, max: 100) |
page |
integer | Page number |
Response:
{
"data": [
{
"id": "01hwxyz...",
"event": "update",
"description": "Updated Invoice #42",
"causer_id": 7,
"causer_name": "Jane Smith",
"subject_type": "App\\Models\\Invoice",
"subject_id": "42",
"ip_address": "192.168.1.1",
"properties": {
"old": { "status": "pending" },
"new": { "status": "paid" },
"diff": {
"status": { "old": "pending", "new": "paid", "type": "changed" }
}
},
"created_at": "2025-04-24T09:30:00.000000Z"
}
],
"meta": { "total": 1420, "per_page": 30, "current_page": 1 },
"next_page_url": "/api/audit/feed?page=2"
}Single audit entry by ULID — includes full properties payload.
Chronological activity for a specific user, ordered by most recent.
| Parameter | Description |
|---|---|
causer_id |
Required. The user's ID |
causer_type |
Defaults to App\Models\User |
per_page |
Default: 30 |
Aggregated statistics for a date range.
Response shape:
{
"total": 12400,
"by_event": { "create": 3200, "update": 7100, "delete": 400 },
"top_causers": [
{ "causer_id": 7, "causer_name": "Jane Smith", "count": 840 }
],
"top_subjects": [{ "subject_type": "App\\Models\\Invoice", "count": 2100 }],
"hourly": { "0": 12, "1": 8, "9": 210, "14": 340 },
"daily": [{ "day": "2025-04-24", "count": 480 }]
}Distinct causers with activity counts — for populating filter dropdowns. Returns the most recently seen name per causer_id, so renamed users appear only once.
Exports the filtered log as a .csv file. Accepts the same filter parameters as /feed. Maximum 10,000 rows per export; exports are tracked for alert rate-limiting.
Exports a formatted PDF audit trail report. Accepts the same filters as /feed. Powered by dompdf/dompdf.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/audit/alerts |
List all rules and default thresholds |
POST |
/api/audit/alerts |
Create an alert rule |
PUT |
/api/audit/alerts/{id} |
Update or enable/disable a rule |
DELETE |
/api/audit/alerts/{id} |
Delete a rule |
GET |
/api/audit/alerts/history |
Recent fired-alert history |
Create rule request body:
{
"name": "Bulk delete guard",
"event_pattern": "delete",
"metric": "deletes_per_min",
"threshold": 10,
"channels": ["email", "slack"]
}Alert rules fire when a threshold is exceeded. The package checks patterns synchronously after every AuditEntry is saved.
| Rule | Default | Environment variable |
|---|---|---|
| Deletes per minute | 10 | AUDIT_ALERT_DELETES_PER_MIN |
| Logins per minute | 20 | AUDIT_ALERT_LOGINS_PER_MIN |
| Updates per minute | 50 | AUDIT_ALERT_UPDATES_PER_MIN |
| Exports per hour | 5 | AUDIT_ALERT_EXPORTS_PER_HOUR |
Custom rules created via the API override these defaults for matching event patterns.
Mailgun (email):
AUDIT_MAILGUN_API_KEY=key-xxx
AUDIT_MAILGUN_DOMAIN=mg.yourdomain.com
AUDIT_ALERT_FROM=audit@yourdomain.com
AUDIT_ALERT_TO=security@yourdomain.comSlack:
AUDIT_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzzLogs are automatically pruned daily by a scheduled command registered in the service provider. The retention window defaults to 90 days:
AUDIT_RETENTION_DAYS=90Run pruning manually at any time:
php artisan audit:prune --days=90Ensure your Laravel scheduler is running:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1Three tables are created by the package migration:
| Column | Type | Description |
|---|---|---|
id |
ULID (PK) | Universally unique, lexicographically sortable |
event |
string | Event key, e.g. update, payment.processed |
description |
string | Human-readable label |
batch_id |
string | Groups entries from one AuditLog::batch() call |
subject_type |
string | Audited model class |
subject_id |
string | Audited model primary key |
causer_type |
string | Actor model class (usually App\Models\User) |
causer_id |
integer | Actor primary key |
causer_name |
string | Snapshot of the actor's name at time of action |
properties |
JSON | old, new, diff, and any custom with() data |
ip_address |
string | Request IP (supports IPv6) |
user_agent |
text | Full user-agent string |
url |
text | Full request URL |
method |
string | HTTP method |
created_at |
timestamp | Indexed for fast date-range queries |
Indexes on: event, batch_id, ip_address, created_at, and composite morphs indexes on (subject_type, subject_id) and (causer_type, causer_id).
Stores custom threshold rules created via the API.
Records every fired alert with the rule name, metric value, threshold breached, and the channels notified.
After publishing with php artisan vendor:publish --tag=audit-config, your config/audit.php exposes:
return [
// Bearer token protecting the REST API (leave empty to disable auth)
'token' => env('AUDIT_TOKEN', ''),
// Prune logs older than this many days
'retention_days' => env('AUDIT_RETENTION_DAYS', 90),
// Fields redacted in stored diffs (case-insensitive substring match)
'masked_fields' => ['password', 'token', 'secret', 'api_key',
'card_number', 'cvv', 'ssn', 'remember_token'],
// Default alert thresholds (overridden by DB rules per event pattern)
'alert_thresholds' => [
'deletes_per_min' => 10,
'logins_per_min' => 20,
'exports_per_hour' => 5,
'updates_per_min' => 50,
],
// Active notification channels
'alert_channels' => ['email', 'slack'],
// Mailgun + Slack credentials
'alerts' => [
'email' => [
'mailgun_key' => env('AUDIT_MAILGUN_API_KEY'),
'mailgun_domain' => env('AUDIT_MAILGUN_DOMAIN'),
'from' => env('AUDIT_ALERT_FROM', 'audit@yourdomain.com'),
'to' => env('AUDIT_ALERT_TO'),
],
'slack' => [
'webhook_url' => env('AUDIT_SLACK_WEBHOOK_URL'),
],
],
];The package ships with a PHPUnit test suite covering DiffService and MaskingService:
composer install
vendor/bin/phpunitTest coverage includes:
- Detecting changed, added, and removed fields in diffs
- Returning an empty diff for identical attribute sets
- Redacting password and token fields (including nested arrays)
- Case-insensitive masking of fields like
PasswordandAPI_KEY
To run tests against a specific Laravel application using Testbench:
composer require --dev orchestra/testbench
vendor/bin/phpunitThis package exposes the REST API. If you would rather not build a UI over it, there is a self-hosted dashboard in a separate repository — activity feed, user timeline, statistics charts, field diff viewer, CSV/PDF export and alert rule management:
github.com/dineshstack/laravel-audit-dashboard
MIT licensed, same as this package. Next.js 16, React 19, Recharts and TanStack Query; dark/light mode, Docker-ready. Entirely optional — the API is designed to be consumed by anything, including your own admin panel or a SIEM.
See CHANGELOG.md for recent changes.
Pull requests are welcome. Please open an issue first to discuss significant changes. All contributions must include tests.
If you discover a security vulnerability, please email dineshlwuae@gmail.com instead of opening a public issue.
MIT. See LICENSE for the full text.
Built by Dinesh Wijethunga — Tech Lead & Senior Full-Stack Engineer.
I write about production Laravel at dineshstack.com. The posts closest to what this package does:
- 5 Laravel Migration Mistakes That Made It Into Production — the schema drift an audit trail surfaces early, including a column still named
36 - Laravel Queues Deep Dive — jobs, batches, chains, and what actually happens to failed jobs; the alert rules here run through the queue
- Pest Tests Against MySQL in GitHub Actions — a CI setup that doesn't quietly lie to you