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=trueuse 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 relationWhat a revision holds
| Column | Holds |
|---|---|
type | RevisionType — attached, updated or detached. |
name | The attribute name. |
old_value, new_value | Storage strings before and after — ciphertext for encrypted attributes. |
old_value_type, new_value_type | AttributeType values before and after. |
old_meta, new_meta | Metadata before and after, cast to Collections. |
owner_type, owner_id | The owning model (owner() morph-to relation). |
actor_type, actor_id | Who made the change (actor() morph-to relation) — with history.actor.enabled; null when nobody was recorded. |
created_at | When 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 freelyWhat 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=adminThe 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 defaultRead 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 nullRevisions 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 cryptoBy 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.