dineshstack/laravel-audit
dineshstack/laravel-audit is a lightweight Laravel package for tracking model changes and recording audit trails. It helps log create, update, and delete events, old/new attribute values, and user context for compliance and debugging.
Install and publish:
composer require dineshstack/laravel-audit
php artisan vendor:publish --tag=audit-config
php artisan vendor:publish --tag=audit-migrations
php artisan migrate
The package targets Laravel 13 specifically, so pin it in composer.json and confirm your app's framework constraint matches before running composer require — a loose ^13.0 app constraint is fine, but on a mixed CI matrix you may need --with-all-dependencies.
Where to look first, in order:
config/audit.php — this is the contract for the whole package: the audit model/table name, which events are recorded (created, updated, deleted, restored), the user resolver, the masking list, and the REST API toggle. Read it end-to-end before writing any code.auditable_type/id, event, old_values, new_values, user_id, ip_address, batch_id, created_at) and check whether values are stored as json or longtext. This dictates what you can index and query.Auditable trait (or equivalent) — check whether it hooks saving/saved or model events, since that determines whether you get dirty attributes or full snapshots.First use case — audit a User model:
use Dineshstack\Audit\Concerns\Auditable;
class User extends Authenticatable
{
use Auditable;
}
Then update a user from a request and inspect the row:
$user->update(['email' => '[email protected]']);
$audit = \Dineshstack\Audit\Models\Audit::latest()->first();
// $audit->old_values => ['email' => '[email protected]']
// $audit->new_values => ['email' => '[email protected]']
// $audit->user_id, $audit->ip_address
That single round-trip proves the three things you actually care about in a review: diff fidelity, attribution, and request context capture.
Trait-on-the-model is the default; be deliberate about which models get it. Apply Auditable to domain models that carry compliance or support weight (User, Invoice, Subscription, Payout) — not to every model. High-churn pivot and cache tables will bloat the audit table fast, and every write now costs an extra insert.
Restrict the recorded fields. If the config exposes an except/only list, use except for noisy columns (updated_at, last_login_at, remember_token, counters). If it doesn't, implement a $auditExclude-style property or override the diff method via the trait. Narrowing the diff makes the UI and the API dramatically more useful — a wall of {"updated_at": ...} entries trains people to ignore audits entirely.
Batch grouping for multi-model operations. Anything that writes across models in one user action (an order + its line items + a payment) should share a batch_id so the UI can render one event rather than twelve:
Audit::batch(function () use ($order, $payload) {
$order->update($payload);
$order->lines()->createMany($payload['lines']);
$order->payment->update(['status' => 'captured']);
});
If the package exposes no explicit batch helper, wrap the operation in a DB::transaction() and set the batch id yourself from a request-scoped value (a UUID resolved in middleware or a Context binding) — but verify first, since the trait may already group by connection-level transaction.
Attribution beyond Auth::id(). The default resolver almost certainly falls back to Auth::user(). That breaks for:
user_id and an impersonator_id.Register a custom resolver in the service provider so all three cases route through one place rather than sprinkling Audit::withUser() calls around:
Audit::resolveUserUsing(fn () => Auth::user() ?? Context::get('audit.user'));
Masking belongs at write time. Configure sensitive columns (password, two_factor_secret, api_token, card_last_four if policy requires) in the masking list so the stored audit row never contains plaintext. Masking on read is a footgun: the data is still in backups, in the REST API's raw responses, and in anyone's DB::table('audits')->get().
Querying. Two consumers: in-app (a "History" tab on a record) and external (support tooling, SIEM). For the former, scope by the auditable relation:
$user->audits()->latest()->paginate(25);
For the latter, enable the REST API and put it behind an auth guard + rate limit in the config — the package ships the routes but not your authorization policy. Never expose the audit API on a public guard; it is a complete record of who changed what.
Testing. Freeze time and assert on the diff, not the row count:
$this->actingAs($admin)->put("/users/{$user->id}", ['email' => '[email protected]']);
$this->assertDatabaseHas('audits', [
'auditable_id' => $user->id,
'event' => 'updated',
'user_id' => $admin->id,
]);
Add one test that asserts a masked field is absent from old_values/new_values — that's the assertion most likely to catch a regression after a config change.
Dirty vs. changes on updated. If the trait reads getDirty() on saving, you get a clean field-level diff. If it reads getChanges() on saved, you get the same thing plus any cast-normalized values. The difference matters for JSON columns and dates: a date cast may serialize to 2026-04-27T00:00:00.000000Z in one path and 2026-04-27 in the other, producing diffs nobody asked for. Test with a cast attribute before trusting the output.
JSON and array casts produce unreadable diffs. Storing a whole settings blob as one old/new pair means every toggle looks like a total rewrite. If that column matters, consider a dedicated audit call for the nested change or accept it and render the diff with a JSON-aware UI.
No user in queued jobs. Jobs dispatched from a web request lose the auth context by the time they run. Either resolve the acting user before dispatch and pass it into the job, or set it via your custom resolver using Context/Bus::pipeThrough. Otherwise your most important writes — the ones that happen asynchronously — will have user_id = null and be useless in an audit.
IP capture behind a load balancer. $request->ip() returns the proxy unless TrustProxies is configured. In a containerized setup this silently records 10.x.x.x for everyone. Verify with a real request through your ingress; don't assume.
Console and seeds pollute the table. php artisan migrate:fresh --seed or a backfill script will generate audit rows attributed to nobody. Guard with Audit::withoutAuditing(fn () => ...) (or whatever the package names it) around seeds, imports, and data fixes — otherwise your first production audit query is full of noise from deployment.
Soft deletes and restores. Confirm the config records deleted/restored events separately. If only deleted is captured, a restore leaves no trail, which is exactly the action an auditor will ask about.
Config caching. After changing config/audit.php, run php artisan config:clear locally and remember config:cache in deploy. A masking rule added but not re-cached is a compliance incident, not a bug.
Retention. There's no automatic pruning in a package like this. Audit tables grow monotonically — plan a scheduled audit:prune --days=365 (or a DELETE ... WHERE created_at < ? chunked job) before launch, and check whether the package ships a command; if it doesn't, write one and put it in routes/console.php. Confirm with legal/compliance first, since retention is often a requirement, not an optimization.
Indexing. The default migration indexes auditable_type/auditable_id and probably user_id. If you query history by date range or by batch_id, add composite indexes — audit tables hit millions of rows faster than you expect, and the "History" tab is a hot path in support tools.
Extension points worth knowing. The custom user resolver, the masking list, the event list, and (usually) an overridable transformAudit/beforeSave hook on the trait. If you find yourself wanting to add a tenant_id column, prefer a transformAudit-style hook or a model listener on the audit model over forking the package — 15 stars means the maintainer isn't going to absorb your fork's divergence.
Package maturity. At 15 stars with a single-maintainer surface, budget for reading the source. The feature list is broad (diffs, IP, attribution, batching, masking, REST API) for a small package, so verify each feature actually behaves as documented before you build a compliance story on it. Pin the version in composer.json ("dineshstack/laravel-audit": "1.*" or an exact constraint) rather than floating ^, and read the changelog on each bump — a field-level diff format change will break your history UI and any assertion tests.
How can I help you explore Laravel packages today?