danielm / laravel-simple-audit
Scalable, queueable audit event logging for Laravel apps, with a fluent builder API.
Requires
- php: ^8.2
- illuminate/database: ^12.4|^13.0
- illuminate/queue: ^12.4|^13.0
- illuminate/support: ^12.4|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0
Suggests
- laravel/boost: Lets AI coding agents pick up this package's AI guidelines and skill automatically (see resources/boost/).
Provides
None
Conflicts
None
Replaces
None
README
Scalable, queueable audit-event logging for Laravel, with a fluent builder:
audit() ->by($actor) ->performedOn($targetModel) ->withProperties(['ip' => $request->ip()]) ->log('user.logged_in');
This package provides the generic engine — an append-only audit_events
table, an immutable model, a queued write path, a fluent builder, and an
archiving command. It has no opinion on what your event types are —
pass any string or BackedEnum to ->log(). The examples/ folder shows
one way to define your own event types and wire them up, using Laravel's
own built-in auth events as a concrete starting point.
Requirements
- PHP 8.2+
- Laravel 12.4+ or 13 (
AuditEvent's scopes use the#[Scope]attribute, added in Laravel 12.4 — composer.json enforces this minimum)
Installing it
composer require danielm/laravel-simple-audit
Laravel's package auto-discovery registers LaravelSimpleAuditServiceProvider
automatically — no manual provider registration needed.
Setup
- Run the migration (it's auto-loaded from the package, no publish needed):
php artisan migrate
- (Optional) Publish the config file to customize table name, connection,
queue, or archiving defaults:
php artisan vendor:publish --tag=simple-audit-config
- (Optional) Publish the migration instead of relying on the package's
copy, if you need to hand-edit the schema:
php artisan vendor:publish --tag=simple-audit-migrations
- Create a dedicated
auditqueue worker (recommended — keep it separate from your default queue so a backlog there never affects audit writes):php artisan queue:work --queue=audit
- (Optional) Copy the examples into your app as a starting point:
php artisan vendor:publish --tag=simple-audit-examples
Seeexamples/README.mdfor what's in there and how to wire it up.
Usage
Log directly with the fluent builder, available globally via audit():
audit() ->by($actor) // who did it — omit for system/console events ->performedOn($targetModel) // what it happened to ->on($relatedModel) // a related record for context, if any ->withProperties(['ip' => $request->ip()]) ->log('user.logged_in');
Or let a listener turn events you already dispatch — Laravel's own built-in events, or your app's domain events — into audit rows automatically, so audit-logging stays out of your business logic:
// app/Listeners/AuditAuthEvents.php (see examples/) public function onLogin(Login $event): void { audit()->by($event->user)->performedOn($event->user)->log('user.logged_in'); }
->log() takes an optional second argument for free text, stored under
metadata['description']:
audit()->by($actor)->performedOn($targetModel) ->log('settings.changed', 'Rotated after suspected leak');
Events that must not be lost if the queue is down can skip it entirely:
audit()->by($actor)->performedOn($targetModel)->synchronously() ->log('settings.changed');
Audit::start() (from Danielm\LaravelSimpleAudit\Services\Audit) is equivalent to
audit() if you'd rather avoid the global helper. Audit::record(...)
is a one-line, non-builder form for simple cases.
Querying
use Danielm\LaravelSimpleAudit\Models\AuditEvent; AuditEvent::ofType('user.logged_in') ->forActor($user) ->recent() ->paginate(); AuditEvent::forSubject($user)->between(now()->subWeek(), now())->get();
If you organized your event types as a BackedEnum, ofType() accepts
that directly too: AuditEvent::ofType(AuditEventType::UserLoggedIn).
If you need app-specific scopes, relations, or casts, point simple-audit.model
at your own subclass:
// config/simple-audit.php 'model' => \App\Models\AuditEvent::class,
namespace App\Models; use Danielm\LaravelSimpleAudit\Models\AuditEvent as BaseAuditEvent; class AuditEvent extends BaseAuditEvent { public function actorUser() { return $this->actor_type === \App\Models\User::class ? $this->actor : null; } }
Configuration reference (config/simple-audit.php)
| Key | Default | Purpose |
|---|---|---|
table |
audit_events |
Table name |
connection |
null (default connection) |
Isolate audit data on its own DB |
queue |
audit |
Queue name for async writes |
queue_connection |
null (default connection) |
Queue connection for async writes |
model |
Danielm\LaravelSimpleAudit\Models\AuditEvent |
Swap in your own subclass |
archive.days |
180 |
Default age cutoff for simple-audit:archive |
archive.disk |
s3 |
Default filesystem disk for archives |
Archiving / retention
php artisan simple-audit:archive --days=180 --disk=s3
Exports events older than the cutoff to newline-delimited JSON on the given disk, then deletes them from the hot table. Schedule it:
// routes/console.php Schedule::command('simple-audit:archive')->monthly();
Testing
Tests use Orchestra Testbench to boot a minimal Laravel app and SQLite in-memory database.
composer install
composer test
tests/TestCase.php loads the package's own migration plus two tiny fixture
models (users, projects) standing in for the actor/subject/reference
models a real app would have.
Notes
- Rows are immutable: updates/deletes throw
LogicException(enforced byAuditEventObserver, wired onto the model via#[ObservedBy]), except viasimple-audit:archive, which deletes through the query builder directly, bypassing Eloquent entirely, for retention purposes. - Never put secrets, tokens, or raw passwords into
metadata/properties. - At very high volume (100M+ rows), consider partitioning
occurred_atby month on MySQL/Postgres, or shipping events to ClickHouse/OpenSearch and keeping only recent data in SQL — this package doesn't do that for you.