iamfarhad/laravel-audit-log
A lightweight, easy-to-use audit logging package for Laravel that automatically records model creations, updates, and deletions. It tracks who changed what and when, supports custom events and tags, and makes querying your audit trail simple and reliable.
The package wires an Auditable trait into Eloquent models and records create/update/delete events into an audit table. Day-to-day, you'll spend your time in three places: the published config/audit-log.php, the models using the trait, and the audit log model/table you query for history.
Minimal setup:
composer require iamfarhad/laravel-audit-log
php artisan vendor:publish --provider="IamFarhad\AuditLog\AuditLogServiceProvider"
php artisan migrate
Invoice rather than User, so you can eyeball the rows it produces:
use IamFarhad\AuditLog\Traits\Auditable;
class Invoice extends Model
{
use Auditable;
}
First realistic use case: an admin-facing "history" tab on a single resource. Fetch the logs for one record, render event, created_at, causer, and a diff of changed attributes. That's the fastest path to value and surfaces config mistakes immediately.
The 1.7.1 addition — a global audit disable switch — is the most relevant new lever for local work. Use it to silence auditing during seeding, imports, and test setup instead of stripping traits or mocking.
Trait per model, config for policy. Keep the trait as the only model-level change. Put decisions about what gets logged (events, excluded attributes, causer resolution) in config so you can adjust behaviour without editing dozens of models.
Global kill switch for bulk/non-interactive paths. New in 1.7.1, wrap seeders, data migrations, CSV imports, and backfill commands:
config(['audit-log.enabled' => false]); // verify exact key against published config
Invoice::withoutAuditing(function () {
// bulk import
});
Even without a package-provided withoutAuditing helper, toggling the config value before the operation and restoring it in a finally block is the reliable pattern. Do this at the outermost boundary of the command/job, not scattered through the code.
Prefer the global flag over per-model disabling. If auditing is disabled per model, the intent is invisible to the next developer. If it's disabled globally in a seeder or test, the intent is obvious and scoped.
Query logs as a relation. Expose history through the trait's relation rather than raw queries, and eager-load the causer to avoid N+1s on history screens:
$invoices = Invoice::with(['auditLogs.causer'])->paginate();
For a single record's timeline, paginate the relation — audit tables grow fast on hot models.
Causer resolution. Make sure the user resolver returns the authenticated user in HTTP contexts and null in console/queue contexts, and that your UI handles the null causer ("System") rather than crashing on a missing relation.
Filter on write, not on read. Exclude noisy or sensitive columns (session tokens, remember_token, large JSON blobs, updated_at) at the config level. Filtering in views means the sensitive data is already in the database.
Integrate with existing activity surfaces. Rather than building a separate audit UI, feed the audit relation into whatever admin panel or Nova/Filament resource already shows the record. The value of audit logs is proximity to the thing being audited.
Test the audit contract, not the package. Assert that updating an invoice produces an audit row with the expected event and changed keys. Don't assert on the package's internal row shape, which changes between versions.
The new global flag will silently break existing tests. Once enabled defaults to true but something in your test bootstrap, .env.testing, or a seeder flips it off, assertions on audit rows fail with no error — just empty results. Add one explicit test that asserting auditing is on, so a stray config change fails loudly.
Config caching. After changing the audit config, run php artisan config:clear in local/dev. A stale cached config is the most common reason "the disable flag doesn't work".
Eloquent events are bypassed by bulk operations. Model::where(...)->update(), upsert, and raw SQL produce no audit rows. If a critical path uses query-builder updates, audit it explicitly or convert to model-level saves. The global disable flag doesn't help here — it's the opposite problem.
Queued auditing and disabled config interact. If the package supports queueing writes, disabling globally in a request doesn't retroactively cancel already-dispatched jobs. In tests, use Queue::fake() or sync drivers so you're not asserting on stale config state from a previous test.
Morph maps. If you use Relation::enforceMorphMap(), old audit rows referencing FQCNs break when you introduce aliases. Decide on morph aliases before you generate production audit data — retrofitting is painful.
Log volume and retention. Audit tables are append-only and unbounded. Plan a pruning command or partition strategy on day one for high-traffic models. Note that pruning is itself an unaudited (or recursively audited) operation — be deliberate about it.
Timezones and created_at. Audit timestamps follow the app timezone config. When correlating audit rows with application logs or external systems, normalise to UTC in queries, not in the stored data.
Extension points to prefer. If the causer resolver or attribute formatter is configurable/overridable, customise there. Avoid subclassing the package's audit model unless necessary — it couples you to internal column names and complicates upgrades. For anything exotic (e.g. logging to an external SIEM), hook the package's events or add a listener on the audit model rather than forking.
Version pinning. 1.7.1 is a small additive change, but this is a single-maintainer package with early-stage API churn. Pin to ^1.7 and read the changelog before minor bumps; treat audit data and column names as something you may need to migrate.
How can I help you explore Laravel packages today?