quarks/laravel-auditors
quarks/laravel-auditors is a Laravel package for tracking changes to Eloquent models, recording who made updates and what changed. It helps you add an audit trail to your application with minimal setup, so you can review model activity and keep a history of edits.
1. Install and verify compatibility first
composer require quarks/laravel-auditors
The last tagged release is 2022-01-08, so before wiring it into a real app:
composer show quarks/laravel-auditors # inspect required illuminate/* versions
php artisan about # confirm service provider auto-discovered
If you are on Laravel 10/11 and the constraint doesn't cover it, either widen it via a composer.json override in a forked repo or pin the app's illuminate/* accordingly. Do this before writing migrations — it is the single biggest risk with a 3-star, single-maintainer package.
2. Publish any config (if the package ships one)
php artisan vendor:publish --provider="Quarks\Auditors\AuditorsServiceProvider"
Check config/auditors.php (name may differ) for the column names and the user resolver — that resolver is the main knob you'll touch later.
3. Add the audit columns to a migration
Keep them nullable and add nullOnDelete so removing a user doesn't cascade-destroy history:
Schema::table('posts', function (Blueprint $table) {
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
});
4. Attach the trait to a model
use Quarks\Auditors\Traits\Auditable; // verify exact namespace from the README
class Post extends Model
{
use Auditable;
}
5. First use case — an auditable Post
Create a post as an authenticated user and confirm the columns populate:
actingAs($user)->post('/posts', ['title' => 'Hello']);
Post::first()->created_by; // => $user->id
$post->update(['title' => 'Hi']);
$post->updated_by; // => $user->id
That round-trip is your smoke test. If created_by is null while authenticated, jump straight to the Gotchas section — it's almost always the auth guard or the resolver, not the trait.
Where to look first in the source
src/Traits/...) — it's small; read the boot* methods to see which Eloquent events are hooked (creating, updating, deleting, restoring, forceDeleting).auth()->id() and whether it supports custom callbacks.Pair the trait with typed relationships so the audit data is actually usable in views and API resources:
class Post extends Model
{
use Auditable;
public function creator(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); }
public function updater(): BelongsTo { return $this->belongsTo(User::class, 'updated_by'); }
public function deleter(): BelongsTo { return $this->belongsTo(User::class, 'deleted_by'); }
}
Eager load them (Post::with('creator', 'updater')) anywhere you render lists — otherwise you'll create an N+1 on every row.
Apply the trait selectively, not globally. Audit columns on every table sound nice and become a migration/backfill burden. Put them on records where "who changed this" is a business or compliance requirement (invoices, tickets, content, user accounts), not on pivot tables or high-churn counters.
Keep soft deletes in the loop. If a model uses SoftDeletes, confirm the package writes deleted_by on delete() and clears it on restore(). Read the trait to check whether restoring is hooked — if it isn't, wrap it yourself:
protected static function booted(): void
{
static::restoring(function (self $model) {
$model->deleted_by = null;
});
}
Handle impersonation / "acting as" explicitly. Most of these packages just call the default guard. When an admin acts on behalf of a user, you usually want the real operator recorded. Override the resolution at the model or request level:
// e.g. inside a middleware or a service
$auditUser = $request->user('admin'); // the real operator, not the impersonated user
If the package exposes a resolver binding, bind it once in a service provider rather than per-model.
Queue and console contexts need a fallback. In a job there is no authenticated user, so columns stay null. That's often correct. When it isn't, pass the actor explicitly through the job and set the attributes before saving, or bind a resolver that reads a scoped "current actor" from the container that both HTTP and jobs populate.
Testing pattern — freeze time and assert deterministically:
public function test_post_records_updater(): void
{
$author = User::factory()->create();
$editor = User::factory()->create();
$post = Post::factory()->create(); // no auth -> created_by null
$this->actingAs($editor);
$post->update(['title' => 'Edited']);
$this->assertSame($editor->id, $post->fresh()->updated_by);
}
Note the first assertion is a feature: unauthenticated creation correctly yields null. Don't "fix" that in tests.
No auth user means silent nulls. Console commands, queued jobs, scheduled tasks, webhooks, and seeders all write null. This is the number-one source of "why is updated_by empty?" — always check whether the code path has an authenticated guard.
Wrong guard. If your app has multiple guards (web, admin, api with Sanctum/Passport), the package likely reads the default. auth()->id() returns null for a user authenticated on a non-default guard. Verify which guard the resolver uses and make it explicit.
Silent bypasses. Model::updateQuietly(), query()->update(), DB::table(...), and raw upserts do not fire Eloquent events, so nothing is recorded. Never trust the audit columns as a complete change log; they tell you last modifier, not history. If you need history, this package is not it — pair it with an activity-log library or owen-it/laravel-auditing.
User IDs only, not morphs. Almost certainly the columns are foreignId('...')->constrained('users'). That breaks the moment you have admin + customer models, or UUID/ULID users. Check the migration assumptions before adding it to any app with polymorphic identities — you may have to override the trait or swap in manual $model->created_by = ....
$fillable surprises. If the trait sets attributes in a creating hook, they bypass mass assignment (fine). If it relies on merging into $attributes, the column must be fillable or will fail in strict mode. Test a create() with Model::preventSilentlyDiscardingAttributes() enabled in a dev environment to catch this early.
Recursion and double-writes. If your User model is also auditable and the resolver lazily loads a user, you can get odd nested saves. Keep audit columns off the User model unless you genuinely need "who created this admin account," and never trigger a save inside an audit resolver.
Foreign keys on user deletion. Without nullOnDelete(), deleting a user either fails the FK or (with cascade) wipes the record with its audit trail. Decide deliberately: null-on-delete preserves the record but loses attribution, so store a denormalized created_by_name if attribution must survive user deletion.
Package staleness is your maintenance risk. With one release in 2022 and ~zero dependents, expect: no PHP 8.3+/Laravel 11 guarantees, no security patches, and a composer.json constraint that may block upgrades. Budget for a fork. Because the trait is small, it's usually cheaper to copy the ~50 lines into app/Support/Auditable.php and own it than to depend on an unmaintained package. If you do fork: keep the namespace, or alias it, so a future upstream merge stays cheap.
Debugging checklist when a column doesn't populate
used on the model (not just imported)?Post::creating(fn ($m) => dump('creating'));auth()->check() true at that exact line? (dd(auth()->id()) in a listener.)composer dump-autoload run after adding the trait?$timestamps = false / custom boot() on the model overriding the trait's boot method? Model booted()/boot collisions silently drop trait hooks if names clash.Extension point worth knowing. The one place these packages usually let you hook in is the user resolver (config callback or container binding). Prefer it over overriding trait methods — it survives package updates and keeps behavior consistent across the app. If no resolver exists, that's your fork's first patch; it's a 5-line change and removes the need for per-model overrides forever.
How can I help you explore Laravel packages today?