romalytar/yammi-audit-log-laravel
Audit log for Laravel that tracks full provenance of every change: actor, origin, and correlation ID across queues and services. Built for distributed, queue-heavy apps to trace who triggered a write and through what execution chain.
Change history and execution tracing for distributed, queue-heavy Laravel apps. Every change carries:
That is what separates it from most audit packages, which record only what changed.
composer require romalytar/yammi-audit-log-laravel
php artisan migrate
php artisan audit-log:ui enable # optional dashboard at /audit-log
Capture is global from the first migration: no traits, no interfaces, no per-model registration. Defaults are safe out of the box (UI off until you enable it, 180-day retention, secrets redacted).
User::first()->update(['name' => 'Test']);
Open /audit-log. The change is already there, with its actor, origin and correlation id filled in. To explore richer data without writing any code, the in-app Playground (/audit-log/settings/playground) generates realistic sample cascades you can trace.

Most Laravel audit tools record what changed on a model. Real systems are distributed:
HTTP request → service → queue → job → model
By the time the row is written, the question that matters during an incident is hard to answer: who actually triggered this change, and through what chain? This package records the full execution context of every change, not just the final write.
A user clicks "pay". A queued job makes the write. A traditional audit log records the job (or "system") and loses the user. Yammi keeps the whole chain:
User: John Doe
↓ dispatches
Job: ProcessPayment
↓ dispatches
Job: ChargeOrder
↓ writes
Order #42 status: pending → paid
actor ChargeOrder (job)
origin John Doe (user)
correlation 550e8400-e29b-41d4-a716-446655440000
That is the moat: actor (who executed the change), origin (who started it), and a correlation id that ties the whole cascade together. Read more in Provenance.
In a production incident, that is the difference:
Order #42 became "paid" at 14:02.
Traditional audit log:
actor = ChargeOrderJob (who triggered it? unknown)
Yammi:
actor = ChargeOrderJob
origin = John Doe
correlation = 550e8400-...
chain = ProcessPayment → ChargeOrder → Order #42
The dashboard draws that whole chain as a tree you can expand and pan, so you see at a glance who started it and which change caused the next:

No traits. No interfaces. No observers. No per-model registration.
// Nothing added to your models. This is already audited:
User::first()->update(['name' => 'Test']);
Install, migrate, done. Capture is global from the first migration. The optional traits exist only for special cases (pivot writes, read access).
Assert what your code audited without hitting the database:
AuditLog::fake();
$order->update(['status' => 'paid']);
AuditLog::assertRecorded(Order::class, $order->id, 'updated',
fn ($record) => $record->diff()->field('status')?->new === 'paid');
AuditLog::assertRecordedCount(1);
fake() routes both automatic (Eloquent) and manual (AuditLog::record) changes into an in-memory fake; assertNotRecorded() and assertNothingRecorded() round out the set.
Core: provenance. Actor, origin and a correlation id on every change, with no per-model setup (the chain above). This is the part that sets it apart from most audit packages.
Optional add-ons, each off or zero-cost until you use it: a time machine, a tamper-evident hash chain, SIEM streaming, anomaly detection, GDPR tooling and multi-tenancy. They build on the core; they are not the point of it. Details in Advanced features.
The provenance core is unchanged; v2.2 deepens completeness, observability and DX.
Bridge to your APM. An incoming W3C traceparent is captured as a trace_id alongside the correlation id and shown on the chain as an Open distributed trace link straight into Datadog, Jaeger or Tempo, so you cross from who-changed-what to the distributed trace that drove it. Set the URL template in Configuration or the Settings UI.

Proven completeness. The write path is fail-open, so a failed audit insert never breaks your request. Those failures are now recorded and surfaced (a Stats banner and a nav badge), so a gap in the trail can no longer pass unnoticed, answering the auditor's "is the log actually complete?".

More in this release:
AuditLog::fake() with assertRecorded() / assertNotRecorded() / assertRecordedCount(), so you assert what your code audited without touching the database (see Testing).HasAuditTrail trait for $order->auditTrail() and $order->auditStateAt($date).AuditLog::placeLegalHold($subject) (or php artisan audit-log:legal-hold) exempts a subject from retention for litigation; held data is never pruned.php artisan audit-log:postman.Most audit packages answer one question:
Yammi answers the whole story:
| Scenario | Yammi | Typical audit package |
|---|---|---|
| User updates a model | ✅ | ✅ |
| User triggers a queued job that updates a model | ✅ Origin preserved | ❌ User context lost |
| Scheduled task updates a model | ✅ Scheduler recorded | ❌ System |
| Admin impersonates a user | ✅ Both identities recorded | ❌ Current user only |
| Multi-job workflow investigation | ✅ Full trace | ❌ Individual events only |
| Incident root-cause analysis | ✅ Execution chain | ❌ Final write only |
created / updated / deleted / restored events, nothing to register per model.audit_log table.Optionally defer that write to the queue (AUDIT_LOG_WRITE_ASYNC=true) or move the table to a dedicated connection.
^8.1, Laravel ^9.0 || ^10 || ^11 || ^12 || ^13, any database Laravel supports.Query Builder ->update() or raw SQL are not seen automatically; record those explicitly with AuditLog::record().audit_log, the settings table, and integrity tables when enabled), which can live on a dedicated connection. Full list in Configuration.A log package lives on your hot path, so the cost is kept deliberate:
field('status')) seek an indexed table instead of scanning the JSON of every row.
Optional subsystems, each off or zero-cost until you use it:
event_version schema contract, a fluent query DSL and value-transition search.Defaults aim to be safe; you keep control of the trade-offs:
password, token, api_key, and more, including nested JSON).web, auth), an optional Gate and a rate limit, serving its own assets (no external CDN).Existing Laravel audit packages, spatie/laravel-activitylog and owen-it/laravel-auditing among them, focus on model changes and user activity. This one focuses on queue-heavy, distributed apps that need execution traceability.
| Capability | Yammi | Spatie |
|---|---|---|
| Model change history | ✅ | ✅ |
| Actor tracking | ✅ | ✅ |
| Origin survives queues | ✅ | ❌ |
| Correlation tracing | ✅ | ❌ |
| Execution chain reconstruction | ✅ | ❌ |
If your current setup covers your needs, keep it. This package earns its place when changes flow through queues and you need to trace them back to a person.
Permanent boundaries. Each would force the audit log to become a source of truth or a real-time system, and that breaks the invariant that makes it safe to install: capture stays off your write path, fails open, is additive, and never changes your data.
It works with zero config. The common switches:
// config/audit-log.php (publish with vendor:publish --tag=audit-log-config)
'capture' => ['mode' => env('AUDIT_LOG_CAPTURE_MODE', 'all')], // all | opt_in
'retention' => ['days' => env('AUDIT_LOG_RETENTION_DAYS', 180)],
'write' => ['async' => env('AUDIT_LOG_WRITE_ASYNC', false)],
'integrity' => ['enabled' => env('AUDIT_LOG_INTEGRITY', false)],
'ui' => ['enabled' => env('AUDIT_LOG_UI_ENABLED', false)],
Settings are also editable from the Settings UI without a redeploy (resolution order: DB row, then config value, then package default). Full reference in Configuration.
The dashboard also ships an in-app documentation page at /audit-log/settings/docs.
MIT
How can I help you explore Laravel packages today?