Laravel Immutable Attributes: guarding Eloquent columns after insert
Some columns must not change once a row exists, such as an invoice number. One declaration on the model makes attempts to change them throw an exception.

On this page
Some values must stay what they were when the row was written. An invoice number that is already printed on a PDF the customer received. The owner of an order. The text a person consented to.
In Laravel, nothing stops your code from changing them. I ran into that while building laravel-waitlist, a package for waitlists: a consent record has to keep the exact text a person agreed to, and a row in its activity log shouldn't change once it is written. I didn't want to copy the same guard into every model, so I wrote laravel-immutable-attributes, which does it with one declaration on the model.
The problem
#Say an invoice gets a number when it is created:
$invoice = Invoice::create([ 'number' => 'INV-1042', 'customer_id' => 7, 'issued_at' => now(),]);Weeks later, some other piece of code loads the invoice, fills it from a form
or an import and saves it. If that code touches number by mistake, nothing
fails. The query runs, the row looks normal, and the damage shows up much later,
when the number in the database no longer matches the one on the PDF.
Why the usual fixes fall short
#Laravel has $guarded, but it only stops mass assignment. A direct assignment
still goes through:
$invoice->number = 'INV-999';$invoice->save(); // writtenThe usual fix is an updating listener that checks isDirty() and throws.
That works for the first model. Then it gets copied into every model that needs
it, and it has gaps:
saveQuietly(),updateQuietly()and saves insideModel::withoutEvents()fire no events, so the listener never runs.- An
updatinglistener or observer registered after it can still change the attribute. increment()anddecrement()write their columns with a query of their own, outside the normal dirty check.
One declaration on the model
#With the package, you add a trait and list the attributes with a PHP attribute:
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;}Inserts are untouched: the attributes are set freely until the row exists.
After that, attempts to change them throw an ImmutableAttributeException
before the update query runs:
use Taldres\ImmutableAttributes\Exceptions\ImmutableAttributeException; try { $invoice->update(['number' => 'INV-999', 'paid' => true]);} catch (ImmutableAttributeException $e) { $e->attributes; // ['number']}Saving the same value is not a change. The check uses Eloquent's own dirty tracking, casts included.
Whole rows can be guarded too, such as the entries of an activity log.
#[ImmutableModel] guards every column:
use Taldres\ImmutableAttributes\Attributes\ImmutableModel; #[ImmutableModel]class ActivityLog extends Model{ use GuardsImmutableAttributes;}Why a model event isn't enough
#To close the gaps of an updating listener, the trait checks again right
before Eloquent builds the update query, in getDirtyForUpdate(), the method
Eloquent calls to collect its columns. It runs after every updating listener,
and on saves without events too:
protected function getDirtyForUpdate(): array{ $dirty = parent::getDirtyForUpdate(); $this->guardImmutableAttributes(array_keys($dirty)); return $dirty;}Increments skip that method, because they write their columns with a query of their own. The trait guards them separately, and that is where the most surprising case came up.
An increment by zero still writes
#$model->increment('number', 0) looks harmless: it adds nothing. The first
version of the guard let it through. But the query still writes the column,
and the database turns the value into a number. In a text column, 001 came
back as 1, without an exception. Now any increment that names an immutable
column throws, whatever the amount.
A smaller lesson of the same kind: a column name with a trailing space, such as
#[Immutable('number ')], used to boot without complaint and guard nothing,
because it never matched the column. A broken declaration like that now fails
when the model boots.
What it deliberately leaves open
#The guard sits on the model. Writes through the Eloquent builder never load a model, so by default they are not guarded:
Invoice::query()->whereKey($id)->update(['number' => 'INV-1042-A']);That is on purpose. The query builder is the escape hatch for deliberate
corrections, data migrations and erasure. Deleting a row also stays possible,
even under #[ImmutableModel].
laravel-waitlist relies on that. Its activity log is an #[ImmutableModel], so
attempts to change a log row throw an exception. When a person is erased, the
package removes their identifying data from the log rows with a query of its
own, while keeping the rows themselves, so the counts remain unchanged:
WaitlistActivity::query() ->where('waitlist_entry_id', $entry->getKey()) ->update(['waitlist_entry_id' => null, 'ip' => null, 'occurred_at' => null]);If a model's queries should be guarded too, it adds GuardsImmutableQueries.
Then a query that writes an immutable column throws, whatever the value,
because it doesn't load the rows it writes. DB::table() and raw SQL take over
as the escape hatch:
use Taldres\ImmutableAttributes\Concerns\GuardsImmutableQueries; #[Immutable('number')]class Invoice extends Model{ use GuardsImmutableAttributes; use GuardsImmutableQueries;} Invoice::whereKey($id)->update(['number' => 'INV-999']); // throws, nothing is writtenInvoice::whereKey($id)->update(['paid' => true]); // allowedThe package is a guardrail against mistakes in application code, not a
security boundary. It doesn't see writes that go around the model, such as
DB::table() or another database client. Where a value must stay unchanged
whoever writes it, enforce that in the database as well, with a trigger or
column privileges.
Try it
#The package needs PHP 8.3 or later and Laravel 12 or 13. There is nothing to publish or configure:
composer require taldres/laravel-immutable-attributesThe README lists exactly what is guarded and what isn't, including how timestamps and column names are matched. A Laravel Boost skill ships with it. Issues and pull requests are welcome.
composer require taldres/laravel-immutable-attributes