taldres / laravel-immutable-attributes
Guard Eloquent model attributes against changes once a row exists: declare them with #[Immutable] and every save that would alter them throws.
Package info
github.com/Taldres/laravel-immutable-attributes
pkg:composer/taldres/laravel-immutable-attributes
Requires
- php: ^8.3
- illuminate/database: ^12.0||^13.0
- illuminate/support: ^12.0||^13.0
Requires (Dev)
- larastan/larastan: ^3.13
- laravel/pao: ^1.0
- laravel/pint: ^1.29
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.6||^5.0
- pestphp/pest-plugin-laravel: ^4.1||^5.0
- pestphp/pest-plugin-type-coverage: ^4.0||^5.0
- phpstan/extension-installer: ^1.4
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-16 07:44:28 UTC
README
Laravel Immutable Attributes
Columns that are set once and never change: declare them on the Eloquent model, and every save that would alter them throws.
#[Immutable('number', 'customer_id', 'issued_at')] class Invoice extends Model { use GuardsImmutableAttributes; } $invoice->number = 'INV-999'; $invoice->save(); // ImmutableAttributeException, nothing is written
Why this package?
Some values must stay what they were when the row was written: an invoice number that is already on a sent PDF, the owner of an order, the text a person consented to. When code changes one of them by mistake, nothing fails. The row looks normal, and the damage shows up much later.
$guardeddoes not help. It only stops mass assignment;$invoice->number = ...still goes through- One declaration instead of a listener per model. The usual fix, an
updatinglistener that checksisDirty(), ends up copied into every model that needs it - Covers the paths a listener misses. Saves and increments without events (
saveQuietly(),incrementQuietly(),withoutEvents()) and changes made by a laterupdatinglistener or observer are caught too - Inserts are untouched. The attributes are set freely until the row exists
- Saving the same value is not a change. A save is checked with Eloquent's own dirty tracking, casts included. An increment is checked by the columns its query writes, because that query bypasses casts
Requirements
- PHP 8.3+
- Laravel 12 or 13
Installation
composer require taldres/laravel-immutable-attributes
There is nothing to publish or configure.
Usage
Add the trait and list the immutable attributes with #[Immutable]:
use Illuminate\Database\Eloquent\Model; use Taldres\ImmutableAttributes\Attributes\Immutable; use Taldres\ImmutableAttributes\Concerns\GuardsImmutableAttributes; #[Immutable('number', 'customer_id', 'issued_at')] class Invoice extends Model { use GuardsImmutableAttributes; }
#[Immutable(['number', 'customer_id'])] works as well, and so does the named
argument columns:. Any other name is rejected, because
#[Immutable('number', except: ['note'])] would otherwise guard note too, the
opposite of what except means on #[ImmutableModel].
A declaration needs at least one column and does not take '*', empty names,
names with whitespace around them, non-strings or JSON paths such as
'options->currency'; the guard compares whole columns, so guard 'options'
instead. A model declared that way fails when it boots with an
InvalidImmutableColumnsException that names the class carrying the attribute.
The whole model
#[ImmutableModel] guards every attribute: once a row exists it is never
updated again, not even by touch() or restore(). Deleting stays possible.
That suits tables whose rows are only ever added, such as logs or ledgers:
use Taldres\ImmutableAttributes\Attributes\ImmutableModel; #[ImmutableModel] class LedgerEntry extends Model { use GuardsImmutableAttributes; }
It takes no columns. Passing one, as in #[ImmutableModel('number')], would
guard more than meant, so it fails when the model boots with an
InvalidImmutableColumnsException; use #[Immutable('number')] instead.
Leaving columns out
Name the columns that may still change with the named argument except, the
way $fillable lets columns through a guarded *. Every other column, and every
column you add to the table later, stays guarded:
#[ImmutableModel(except: ['views', 'updated_at'])] class Article extends Model { use GuardsImmutableAttributes; } $article->increment('views'); // allowed $article->update(['title' => 'New title']); // ImmutableAttributeException
except is a named argument on purpose: a positional #[ImmutableModel('views')]
would read as "guard everything but views", the opposite of what
#[Immutable('views')] means, so it is rejected.
Every save and every increment also writes updated_at. Leave it out as well,
as above, or the counter throws; Model::withoutTimestamps() is the other way
out. A column that #[Immutable] names stays guarded even if except lists it.
Inheritance and presets
Declarations stack: #[Immutable] and #[ImmutableModel] on a parent model, on
the model itself, and on any trait they use are merged, so a child model can add
attributes but never release one. With several #[ImmutableModel], only the
columns that all of them leave out stay mutable, and one without except leaves
out nothing.
Both attributes can be extended for a named preset. The subclass needs its own
#[Attribute] marker:
use Attribute; use Taldres\ImmutableAttributes\Attributes\ImmutableModel; #[Attribute(Attribute::TARGET_CLASS)] class Ledger extends ImmutableModel { // }
A preset can fix its except by calling parent::__construct(except: ['memo'])
in its own constructor.
Deciding at runtime
Override getImmutableAttributes() and return the guarded attributes, or ['*']
for all of them, as Laravel's getGuarded() does. Base the decision on the
stored state with getOriginal(), not on unsaved attributes; otherwise the save
that finalizes the row is already checked against the stricter list and throws:
public function getImmutableAttributes(): array { return $this->getOriginal('finalized_at') !== null ? ['*'] : ['number']; }
With ['*'], getMutableAttributes() lists what stays mutable, the same list
except fills. Override it as well to decide that at runtime; an attribute that
getImmutableAttributes() names is guarded all the same.
The list must hold column names as non-empty strings without whitespace around
them. Anything else throws an
InvalidImmutableColumnsException naming the model, the first time the list is
used.
getOriginal() is the state this model instance loaded, not the row as it is now.
Two limits follow:
- A query that leaves
finalized_atout of itsselect()makesgetOriginal()returnnull, so the model counts as not finalized. - If another process finalizes the row after the model was loaded, this instance
still treats it as open and saves.
refresh()narrows the window but does not close it. Where finalization must be binding, load the row inside a transaction withlockForUpdate()and save before the transaction ends.
The exception
A violation throws Taldres\ImmutableAttributes\Exceptions\ImmutableAttributeException,
a RuntimeException, before the update query runs:
try { $invoice->update(['number' => 'INV-999', 'paid' => true]); } catch (ImmutableAttributeException $e) { $e->model; // App\Models\Invoice $e->key; // 42 $e->attributes; // ['number'] }
The model keeps its unsaved changes; call $invoice->refresh() to discard them.
Until then every save of that model throws, including an increment() of
another column.
What is guarded
| Guarded | Not guarded |
|---|---|
save(), update(), push() |
Query builder writes: Invoice::whereKey($id)->update([...]), upsert(), raw queries |
fill() and forceFill() followed by a save |
increment() and decrement() on a model that does not exist yet, which Laravel runs as a query on the whole table |
saveQuietly(), updateQuietly() and saves inside Model::withoutEvents() |
Deleting and soft deleting |
Changes made by updating listeners and observers |
Parent timestamps touched through $touches |
increment() and decrement(), including the extra columns and the update timestamp they write; an immutable extra column throws even with its current value |
incrementEach() and decrementEach() before Laravel 13.3, which Laravel runs as a query on the whole table |
incrementQuietly() and decrementQuietly(), and increment() and decrement() inside Model::withoutEvents() |
|
incrementEach() and decrementEach() on Laravel 13.3 and later, and on Laravel 13.20 and later their Quietly variants |
|
touch() when the touched column is immutable, such as updated_at under #[ImmutableModel] |
|
restore() when deleted_at is immutable, such as under #[ImmutableModel] |
The query builder stays open on purpose: it is the escape hatch for deliberate corrections, data migrations and erasure.
An attribute that was not selected, as with select('id', 'paid'), counts as
changed once you set it, even to the stored value.
The check for saves without events lives in getDirtyForUpdate(). If the model
overrides that method, PHP uses the model's method instead of the trait's, and
parent::getDirtyForUpdate() skips the trait, so the check is gone. Alias the
trait method and call it instead:
use GuardsImmutableAttributes { getDirtyForUpdate as protected guardedDirtyForUpdate; } protected function getDirtyForUpdate(): array { $dirty = $this->guardedDirtyForUpdate(); // Your own adjustments. return $dirty; }
The same applies to incrementOrDecrement() and, on Laravel 13.3 and later,
incrementOrDecrementEach(): the trait overrides them to check the columns an
increment writes, before the increment runs and whether or not it fires events,
so a model that defines them needs the same aliasing and the trait's return
type, int|false. A rejected increment leaves the model as it was.
A key counts as the column it writes, whatever its qualifier: invoices.number
counts as number, and options->currency counts as options. Case does not
count either, so NUMBER is number, except on PostgreSQL, where Laravel quotes
identifiers and NUMBER is a different column. SQLite folds ASCII case only, so
there CAFÉ and café are two columns. Entries returned by
getImmutableAttributes() are matched the same way, so 'options->currency'
there guards the whole options column. The exception names the attribute as
declared.
An increment by 0 of an immutable column throws as well. Its query still
writes the column, and the database turns the value into a number, so 001 in
a text column becomes 1.
AI agents
The package ships a Laravel Boost skill,
laravel-immutable-attributes-development. Run php artisan boost:install and
select taldres/laravel-immutable-attributes among the third-party packages. A
non-interactive install only picks up packages listed under packages in
boost.json.
Development
Built on the Laravel package skeleton:
composer test # PHPStan level 10, Pint, type coverage, Pest composer lint # Pint
See CONTRIBUTING and the changelog.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
License
MIT. See LICENSE.md.
