NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Attributes for Laravel

History & audit trail

Enable attributes.history.enabled to record an old→new revision whenever an attribute is attached, updated — value or meta — or detached. It is off by default — no revision rows are written until you opt in:

ATTRIBUTES_HISTORY=true
use RoundlyConsulting\Attributes\Facades\Attributes;

Attributes::for($product)->history();        // Collection<AttributeRevision> — newest first
Attributes::for($product)->history('color'); // filtered by attribute name

$product->history('color');   // model shorthand
$product->attributeHistory(); // the underlying MorphMany relation

What a revision holds

ColumnHolds
typeRevisionType — attached, updated or detached.
nameThe attribute name.
old_value, new_valueStorage strings before and after — ciphertext for encrypted attributes.
old_value_type, new_value_typeAttributeType values before and after.
old_meta, new_metaMetadata before and after, cast to Collections.
owner_type, owner_idThe owning model (owner() morph-to relation).
actor_type, actor_idWho made the change (actor() morph-to relation) — with history.actor.enabled; null when nobody was recorded.
created_atWhen the change happened.
use RoundlyConsulting\Attributes\Enums\RevisionType;

foreach ($product->history('color') as $revision) {
    $revision->type;            // RevisionType::Attached | Updated | Detached
    $revision->old_value;       // previous storage string, or null
    $revision->new_value;       // new storage string, or null on detach
    $revision->old_value_type;  // 'string', 'integer', ...
    $revision->new_value_type;
    $revision->old_meta;        // Collection|null
    $revision->new_meta;
    $revision->created_at;

    $revision->type->is(RevisionType::Updated); // bool
}

$product->attributeHistory('color')->limit(10)->get(); // a relation — query it freely

What is recorded

  • attached — the first write of a name through any writer, meta() included, and re-attaching a detached name.
  • updated — writing a value or meta to a name that is attached, including values re-sent by a sync.
  • detached — every removal of an attached name: forget(), forgetExcept() and the extras sync() drops, plus their model methods — and a permanent delete of the owner.

Who made the change

Turn on history.actor.enabled as well and every revision also records its actor — who made the change — in the nullable actor columns. Run the update_attribute_revisions_add_actor migration first (see Installation); the option stays off until you enable it:

ATTRIBUTES_HISTORY=true
ATTRIBUTES_HISTORY_ACTOR=true

# optional: take the default actor from another guard
ATTRIBUTES_HISTORY_ACTOR_GUARD=admin

The actor is decided in this order:

  • the actor a write names — Attributes::for($model)->by($user)->set(…); by(null) records nobody, for a system change;
  • else what your resolver returns — Attributes::resolveActorUsing(fn () => …), a saved model or null;
  • else the signed-in user of history.actor.guard (the default guard when null), when it is a saved Eloquent model — otherwise nobody.
use RoundlyConsulting\Attributes\Facades\Attributes;

// The signed-in user of the configured guard is recorded by default
Attributes::for($product)->set('price', 12);

// Name the actor for this handle's writes — an admin acting for a customer
Attributes::for($product)->by($admin)->set('price', 10);
Attributes::for($product)->by($admin)->sync(['price' => 10, 'color' => 'red']);

// by(null) records nobody — a system change, such as a background import
Attributes::for($product)->by(null)->setMany($importedValues);

by() names the actor for every write of that handle — set(), setMany(), setValidated(), sync(), forget(), forgetExcept(), meta() and stage() — and only for that handle. An unsaved model throws AttributesException, and so does a resolver that returns anything but a saved model or null. Writes inside a by() call restore the outer actor when they finish, so nested writes don’t leak their actor.

Register a resolver in a service provider. It is never called while history is off, and it is process-wide — under Octane it persists across requests, which is what you want for a resolver:

// app/Providers/AppServiceProvider.php — boot()
use RoundlyConsulting\Attributes\Facades\Attributes;

Attributes::resolveActorUsing(fn () => auth('admin')->user() ?? auth()->user());

Attributes::resolveActorUsing(null); // back to the guard default

Read the actor from a revision through the actor() relation, or the raw columns through actorType() and actorId() — those return null, never throw, when the actor columns were never migrated:

$revision = $product->attributeHistory('price')->with('actor')->first();

$revision->actor;        // the User model — null when nobody was recorded
$revision->actorType();  // the stored actor_type (class or morph alias), or null
$revision->actorId();    // 42, or null

Revisions in an API

AttributeRevisionResource renders a revision for an API. The owner is left out, because history is listed per owner:

use RoundlyConsulting\Attributes\Http\Resources\AttributeRevisionResource;

// A product's history for an API, newest first
return AttributeRevisionResource::collection($product->history());
{
  "id": 12,
  "name": "price",
  "type": "updated",
  "old_value": "12",
  "new_value": "10",
  "old_value_type": "integer",
  "new_value_type": "integer",
  "old_meta": null,
  "new_meta": null,
  "actor": { "id": 42, "type": "User" },
  "created_at": "2026-11-09T09:30:00.000000Z"
}

old_value and new_value are the stored text, so an encrypted attribute’s revision carries its ciphertext, never the plain value. actor is null for a revision without one; otherwise it renders through resources.revision_actor. The default gives { id, type }, where type is the actor model’s class basename (User) — under a morph map the mapped model’s basename, never the alias — so no class name goes over the wire. It reads the revision’s own columns, so it adds no query per row and a deleted actor still renders.

Your own actor shape

Point resources.revision_actor at a JsonResource subclass of your own to add a name or an avatar. It receives the AttributeRevision — read actorId(), actorType() or the actor relation, and eager-load actor when you do:

// config/attributes.php
'resources' => [
    'revision_actor' => App\Http\Resources\RevisionActorResource::class,
],

// app/Http/Resources/RevisionActorResource.php — receives the AttributeRevision
final class RevisionActorResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->resource->actorId(),
            'name' => $this->resource->actor?->name,
        ];
    }
}

// Eager-load the actor so each row doesn't query it
return AttributeRevisionResource::collection(
    $product->attributeHistory()->with('actor')->latest()->get(),
);

Anything that is not an instantiable JsonResource subclass — a missing class, the JsonResource base, an abstract resource — throws InvalidConfigurationException naming the key; null or blank uses the default.

Pruning revisions

attributes:prune only touches attributes — pruning the revisions table is left to your application, for example with a scheduled query:

// routes/console.php
use Illuminate\Support\Facades\Schedule;
use RoundlyConsulting\Attributes\Models\AttributeRevision;

Schedule::call(function (): void {
    AttributeRevision::query()
        ->where('created_at', '<', now()->subYear())
        ->forceDelete();
})->weekly();

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.