Skip to content
All posts

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.

Dennis Petersmann · 3 min read · Updated
An invoices row whose number, customer_id and issued_at columns are locked. An update to the number is crossed out and throws an ImmutableAttributeException.

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:

php
$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:

php
$invoice->number = 'INV-999';$invoice->save(); // written

The 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 inside Model::withoutEvents() fire no events, so the listener never runs.
  • An updating listener or observer registered after it can still change the attribute.
  • increment() and decrement() 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:

app/Models/Invoice.php
php
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:

php
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:

php
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:

src/Concerns/GuardsImmutableAttributes.php
php
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:

php
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:

php
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:

php
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]);        // allowed

The 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:

bash
composer require taldres/laravel-immutable-attributes

The 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.

Laravel package · ComposerPHP · MIT
Laravel Immutable Attributes
An open-source package that guards Eloquent attributes such as an invoice number against changes once a row exists: declared on the model, every save that would alter them throws an exception.
composer require taldres/laravel-immutable-attributes

More posts