jarek-paprocki/laravel-audit
Laravel Audit provides automatic model auditing for Laravel apps, tracking create, update, and delete events so you can see who changed what and when. It stores revisions and lets you review or revert changes.
The package is published as owen-it/laravel-auditing (the jarek-paprocki/laravel-audit name in the brief points at the same codebase/lineage). It records model change history into an audits table by hooking Eloquent events, and gives you an API to read old/new values, who did it, and when.
Install and publish:
composer require owen-it/laravel-auditing
php artisan vendor:publish --provider="OwenIt\Auditing\AuditingServiceProvider" --tag="config"
php artisan vendor:publish --provider="OwenIt\Auditing\AuditingServiceProvider" --tag="migrations"
php artisan migrate
Auto-discovery handles the service provider on modern Laravel; older versions need OwenIt\Auditing\AuditingServiceProvider registered in config/app.php.
Where to look first:
config/audit.php — driver, audit implementation, user resolver, console behaviour, timeline, strict mode.create_audits_table migration — check the JSON columns and indexes before you have data.Auditable trait — this is where the event hooks live; skim bootAuditable() to understand exactly when an audit is written.First use case — audit an order:
use OwenIt\Auditing\Contracts\Auditable;
use OwenIt\Auditing\Auditable as AuditableTrait;
class Order extends Model implements Auditable
{
use AuditableTrait;
}
$order->update(['status' => 'paid']);
$audit = $order->audits()->latest()->first();
$audit->event; // "updated"
$audit->getModified(); // ['status' => ['old' => 'pending', 'new' => 'paid']]
$audit->getMetadata(); // user, ip, user_agent, url, created_at
That getModified() shape is what you render in an admin timeline or feed to an external compliance store.
Allow-list the attributes you actually care about. Most real apps don't want the full attribute payload (base64 blobs, API tokens, internal flags). Use include/exclude lists rather than auditing everything:
protected $auditInclude = ['status', 'total', 'assigned_to'];
// or
protected $auditExclude = ['api_token', 'last_seen_at'];
Combine with protected $auditStrict = true; so an audit is only written when one of the included attributes changed — this prevents noise from touch() and incidental updates.
Control which events are recorded per model. Defaults are created/updated/deleted/restored. Override when needed:
protected $auditEvents = ['created', 'updated', 'deleted'];
For one-off operations you can switch the event name on the instance ($model->auditEvent = 'approved') and toggle auditing with Order::disableAuditing() / Order::enableAuditing().
Shape the payload once, centrally. Override transformAudit() to add tenant/request context columns, and resolveData() if you need to normalise values before they are persisted:
public function transformAudit(array $data): array
{
$data['tenant_id'] = tenant('id');
return $data;
}
Do this in a shared base model or trait so every auditable model behaves identically.
Handle sensitive attributes with modifiers. Instead of excluding a field entirely, store a hash or redacted value:
protected $attributeModifiers = [
'email' => fn ($value) => $value ? sha1($value) : null,
];
Resolve "who" correctly. The default resolver reads the authenticated user from configured guards. In API/token contexts, impersonation flows, or multi-guard apps, replace it in config/audit.php:
'resolver' => [
'user' => fn () => auth()->user() ?? request()->user(),
],
If you use morph maps, also verify user_type on the audit matches the alias — otherwise historical rows become inconsistent after a refactor.
Use tags for grouping and filtering. Tag audits by workflow (billing, approval) via generateTags() or the config tags array, then filter admin UI queries: Audit::where('tags', 'like', '%billing%').
Fan out with events, don't put side effects in the model. Listen to OwenIt\Auditing\Events\Audited to push to a SIEM, broadcast, or queue a notification. The Auditing event fires before the insert and can veto an audit by returning false.
Swap the audit model or driver for scale. Set config('audit.implementation') to your own model implementing OwenIt\Auditing\Contracts\Audit to add columns or soft deletes. For high write volume, implement OwenIt\Auditing\Contracts\AuditDriver and ship writes to a separate store.
Query patterns for reporting. JSON path queries work on MySQL/Postgres via Laravel's -> syntax:
Audit::where('auditable_type', Order::class)
->where('new_values->status', 'paid')
->latest()
->paginate(50);
Eager load audits.auditable for admin tables, and prefer withCount('audits') over loading full audit collections in list views.
Model::where(...)->update(...), DB::table(), and raw SQL don't fire model events. If compliance requires coverage, force per-model saves or add a separate audit path.saveQuietly() / updateQuietly() skip auditing — they suppress model events by design. Audit critical workflows with regular saves.Relation::enforceMorphMap() before recording any audits. Renaming a model class after the fact orphans every historical auditable_type; plan for a migration if you must rename.config('audit.console') is false; if you need audits from jobs, set a resolver that reads an explicitly passed user ID rather than relying on auth().updated audits only fire when attributes are dirty. touch(), timestamp-only updates, or saving unchanged models produce nothing. Set $auditTimestamps = true if you want updated_at included as a modified attribute.created audit has empty old_values; deleted has only old values. UI code that assumes both sides exist will break.->using(PivotModel::class) with the trait, or record a synthetic audit entry.text. Watch max_allowed_packet if you audit large payloads.$auditThreshold caps audits per model by deleting the oldest — convenient, but it silently destroys history. Prefer a scheduled archive job for anything compliance-adjacent.$auditStrict and $auditInclude interact. Strict mode compares dirty keys against the include list; an update that changes only excluded attributes is discarded. Test this explicitly.implementation or the audit model class does not migrate old rows. Any column your custom model expects must exist before you swap it in.config:cache after changing config/audit.php. A resolver closure in the config file is fine, but stale caches are a frequent "why is user_id null" cause.Order::disableAuditing() in setUp) unless the audit itself is under test; otherwise factory-heavy tests produce large, meaningless audit tables and slow down RefreshDatabase.auditable_type/auditable_id, user_id, event, and created_at are indexed; timeline queries are almost always "for this record, newest first".How can I help you explore Laravel packages today?