yajra/laravel-auditable
Laravel Auditable automatically tracks model changes, logging who created, updated, or deleted records along with old and new values. A simple trait adds full audit trails to any Eloquent model, making it easy to see exactly what changed and when.
Architecture fit. This is a small, additive package — a trait on Eloquent models plus two indexed actor columns — not an auditing subsystem. It fits monoliths and conventional Laravel APIs where write paths go through Eloquent models. It does not fit event-driven architectures, CDC pipelines, warehouse exports, or SIEM ingestion, and it should not be positioned as an evidence-grade compliance system.
The critical architecture finding is a product-identity discrepancy, not a code-quality issue. The repository description promises create/update/delete events with old and new values in a dedicated audits table; the hands-on technical assessment describes the trait as stamping created_by / updated_by on the model itself. Those are materially different products:
| Capability | Stamping | History table |
|---|---|---|
| "Who last touched this row" | Yes | Yes |
| Field-level before/after diffs | No | Yes |
| Reconstruct past record state | No | Yes |
| Satisfies most auditor requirements | Unlikely | More likely |
Everything downstream — roadmap commitment, certification milestone, enterprise questionnaire answers — depends on which one this is. Treat that as a blocking gate, not a discovery task.
Integration feasibility. High. composer require with a framework-major-pinned constraint, publish config and migration, add two columns, apply the trait, eager-load two relations. The first visible use case (an admin table column "Created by Alice · 3 days ago" plus a updated_by = me filter) is afternoon-scale. Removal is equally cheap, which keeps lock-in low.
Technical risk.
->update(), insert(), upsert(), increment(), raw SQL, and saveQuietly() / withoutEvents() all bypass the trait with no error. Authorship goes stale with zero signal.Key questions to close before commitment:
audits row with old_values / new_values, or two columns on the model? Run the spike on a throwaway branch and look at the table.->update( and insert(.nullOnDelete erases it)?Stack fit. Requires a current or immediately prior Laravel major; package versions track framework majors, so ^13.0 for Laravel 13 and ^12.0 for Laravel 12. CI runs PHP 8.5. Teams further behind should stay on ^12.0.2 and not chase this release. The constraint is not decorative — pin it explicitly.
Migration path.
--force over local edits; diff the stub on every upgrade.foreignId with nullOnDelete, plus an index on each.creator / updater in every list view; two belongsTo per row is a textbook N+1. Serialize through an API Resource so null actors are consistent.Compatibility work that is actually configuration, not code. Multi-guard setups (web + admin), impersonation sessions, and per-tenant user models all need the config's user model/resolver adjusted and then verified with auth()->id() assertions. Prefer composing your own trait over this one to add tenant-scoped or multi-guard resolution rather than forking.
Write-path compatibility is the real integration cost. Enumerate every path that mutates an audited model — HTTP handlers, queued jobs, scheduled commands, imports, admin tooling, state machines (publish(), approve()). Bulk paths need converting to each->update() or chunked loops only where authorship matters; do that conversion as a scoped task with test coverage, not as an open-ended refactor.
Sequencing.
spatie/laravel-activitylog, owen-it/laravel-auditing) for the handful of models where diffs matter — financial records, permissions, contracts — and keep this package for the cheap indexed columns that drive filters and admin columns. If attribution suffices, proceed.Upgrades. v12 → v13 lists only Laravel 13 support, PHP 8.5 CI, and semantic release tooling — meaning "major" is now a proxy for framework support, not breaking change. Bundle the package bump into the Laravel upgrade workstream, diff the published migration stub against the live table, and regression-pass create/update/delete plus bulk operations. Do not schedule it as an independent task.
Maintenance. Low absolute effort, permanently coupled cadence. Every Laravel major drags an audit-package major with it, forever. Budget as recurring platform maintenance in the delivery calendar rather than a one-time integration. The published config and migration are ours to maintain once edited; each upgrade requires a manual diff.
Support load. One predictable, recurring ticket class: "updated_by is stale / blank." Root causes are enumerable and worth documenting up front — bulk query-builder updates, queued and scheduled jobs with no authenticated user, saveQuietly(). Decide the convention once (null means "system") and render "System" in the UI instead of a blank cell, or support files one bug per affected row. The same guidance should land in the onboarding doc before the first ticket arrives.
Scaling. If it is genuinely stamping: two nullable indexed FK columns per audited table, negligible query cost, and the main scaling discipline is eager-loading. If it turns out to write an audit table: monotonic growth with no natural ceiling, which turns into a cost line, a retention policy, an archival/partitioning task, and an awkward GDPR conversation about whether audit records are customer-exportable or customer-deletable. That asymmetry is a reason to resolve the product-identity question before, not after, the schema ships — the retention conversation is much cheaper before there is data.
Failure modes to design against:
created_by for user-initiated tables and alert on drift.nullOnDelete preserves the row and erases the author. If attribution has compliance or dispute value, snapshot created_by_name / created_by_email at write time, or forbid hard deletes of users.updated_by reflects whoever last called save(), which may be an approver, a bulk job, or a support engineer. Don't let it answer approval questions — add explicit approved_by / approved_at.Ramp-up. Very low, and unusually so for a dependency. The public surface is two relations, one trait, one config file, and one migration; the trait source is short enough to read in full rather than search for. Plan for roughly half a day of engineer onboarding, most of it spent on the coverage-gaps list (which write paths bypass Eloquent events) rather than on the package itself. The absence of a third-party community corpus — zero dependents — means Stack Overflow will not help; the correct instruction to the team is "read src/AuditableTrait.php first," and that instruction should be written down.
Net for a TPM. Cheap to adopt, cheap to remove, low operational surface, and it converts a recurring feature request and a procurement checklist item into an afternoon of work — provided the spike confirms the data model matches the requirement. The package's risk is almost entirely concentrated in that one unresolved question, and the cost of getting it wrong is a backfill we cannot perform. Gate on the spike; then adopt for attribution, and pair with a real history-log package wherever diffs are genuinely required.
How can I help you explore Laravel packages today?