Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Auditable Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install pinned to your framework major. Releases track Laravel versions, so the constraint carries meaning:

composer require yajra/laravel-auditable:^13.0   # Laravel 13
composer require yajra/laravel-auditable:^12.0   # Laravel 12

Resolve the behavior question before writing application code. The package description promises an audits table with old/new JSON values; hands-on evaluation of this package describes it as a stamping utility that maintains created_by / updated_by on the host table. Those are different products with different schemas. Spend fifteen minutes on a throwaway branch:

$post = Post::create(['title' => 'x']);   // actingAs user 7
$post->update(['title' => 'y']);
dump(DB::table('posts')->latest('id')->first());
dump($post->getChanges());

Two columns on posts and no second table means stamping — the answer to "who last touched this row," not "what did it look like before." Everything below assumes stamping, which is the trait's documented behavior.

Publish and write the columns yourself.

php artisan vendor:publish --provider="Yajra\Auditable\AuditableServiceProvider"

Don't trust the stub verbatim. You want nullability, indexes, and an ON DELETE SET NULL FK:

Schema::table('posts', function (Blueprint $table) {
    $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
    $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
    $table->index('created_by');
    $table->index('updated_by');
});

Apply the trait and confirm the relations exist.

class Post extends Model
{
    use \Yajra\Auditable\AuditableTrait;
}

$post->creator;   // belongsTo User
$post->updater;   // belongsTo User

Read these three files in this order: src/AuditableTrait.php (which events it hooks, what happens with no authenticated user, relation names — this is ~90% of your daily surface and ~100 lines), config/auditable.php (user model and resolver), then the published migration stub. The service provider is trivial.

First use case worth shipping: an admin column showing "Created by Alice · 3 days ago" plus a updated_by = me filter. That's an afternoon of work and it's the feature the package actually buys you.

Implementation Patterns

Eager-load both relations in every list view. Two belongsTo per row is a textbook N+1 and it will not be obvious until the admin table pages slowly:

$posts = Post::with(['creator:id,name', 'updater:id,name'])->paginate();

Serialize through an API Resource so null actors stay consistent:

return [
    'created_by' => $this->whenLoaded('creator', fn () => $this->creator?->only('id', 'name')),
    'updated_by' => $this->whenLoaded('updater', fn () => $this->updater?->only('id', 'name')),
];

Never add created_by / updated_by to $fillable. The trait sets them from the session. Making them mass-assignable hands clients the ability to forge authorship — a genuine security regression, not just a code smell.

Backfill existing rows in a separate, idempotent migration. Historic rows will show a blank author. Resolve to a synthetic "system" user rather than guessing:

DB::table('posts')->whereNull('created_by')
    ->update(['created_by' => $systemUserId]);

Batch it on large tables and keep it out of the schema migration so it can be re-run.

Compose the trait into a base model rather than scattering it. A BaseModel (or a small, documented list) limits the blast radius of enabling/disabling and gives you one place to override relations:

trait TenantAuditable
{
    use \Yajra\Auditable\AuditableTrait;

    public function creator()
    {
        return $this->belongsTo(config('auditable.user_model'), 'created_by');
    }
}

Don't overload updated_by with domain semantics. If you have publish(), approve(), or a state machine, updated_by only reflects the last save(). Add explicit approved_by / approved_at columns where the requirement is "who approved" rather than "who last touched."

Pair it with a real activity log where field-level diffs matter. The pragmatic production split: this package for the cheap indexed columns that drive filters and admin views, plus spatie/laravel-activitylog or owen-it/laravel-auditing on financial records, permissions, and anything a compliance reviewer will ask about. Trying to reconstruct history from updated_at is the trap you're avoiding.

Assert it in the feature test you were writing anyway:

$this->actingAs($user)->post('/posts', ['title' => 'x']);
$this->assertDatabaseHas('posts', ['title' => 'x', 'created_by' => $user->id]);

Gotchas and Tips

Bulk operations bypass Eloquent events and stamp nothing. Post::where(...)->update([...]), insert(), upsert(), increment(), raw query-builder calls, saveQuietly(), and Model::withoutEvents() all silently skip the trait. Grep models carrying the trait for ->update( on the query builder and convert to ->each->update() or a chunked loop where authorship matters. This is the top source of "why is updated_by stale?" tickets.

CLI, queue workers, and scheduled commands have no authenticated user — columns will be null. That's correct behavior, not a bug. Pick a convention once (null means "system"), and render "System" in the UI so support doesn't file a ticket per row.

Multiple guards and tenant-scoped user tables are the real configuration exercise. admin and web guards, or per-tenant users tables, require adjusting the config's user model/resolver and confirming which guard the trait reads. Also verify auth()->id() resolves to the impersonated user during impersonation sessions if you have that feature.

User deletion is a policy decision. nullOnDelete preserves the row but erases the attribution. If attribution itself carries compliance value, snapshot created_by_name / created_by_email at write time, or forbid hard deletes of users referenced by audited records.

Don't put the trait on the users table without deliberate thought — created_by on a user row is usually meaningless or actively confusing in the UI.

Re-publishing config or migrations overwrites your edits. Publish once, then diff on upgrades. Never --force blindly.

Treat upgrades as a migration diff, not a version bump. v12 → v13 was a "major" whose changelog lists only Laravel 13 support, PHP 8.5 CI, and Semantic Release — no breaking changes. Major now proxies framework support. Before any bump: diff the published stub against your live table, run against a production-shaped snapshot, and regression-pass create/update/delete plus bulk operations. Couple the package bump to the Laravel upgrade workstream; they're permanently linked.

There is no kill switch. You can't disable the trait at runtime. If you need one, gate at the config/user-resolver level, or feature-flag the display and accept that writes continue. If you must truly disable it you're removing the trait — which is why applying it via a base model or a short documented list beats use AuditableTrait across fifty files.

Extension points: the config's user resolver (custom guards/user models) is the main one. Beyond that, override the creator/updater relations or the id-resolution method in a composing trait that wraps this one — the cleanest way to add tenant scoping without forking.

Audit-style data grows monotonically. Decide retention, archival, and export policy early; it touches cloud spend, privacy language, and whether audit rows are customer-exportable or customer-deletable under GDPR.

Zero third-party dependents means almost no Stack Overflow corpus. When something behaves oddly, read the trait — it's small, self-contained, and faster than searching.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager