Search by

taldres / laravel-immutable-attributes

Taldres

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

Homepage

pkg:composer/taldres/laravel-immutable-attributes

Statistics

Installs: 2 350

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-10-09 06:26 UTC

This package is auto-updated.

Last update: 2026-10-11 16:08:54 UTC


README

Laravel Immutable Attributes — protect individual Eloquent columns from changes

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.

Packagist Downloads Tests PHPStan Laravel PHP License

#[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.

  • $guarded does not help. It only stops mass assignment; $invoice->number = ... still goes through
  • One declaration instead of a listener per model. The usual fix, an updating listener that checks isDirty(), ends up copied into every model that needs it
  • Covers the paths a listener misses. Saves without events (saveQuietly(), withoutEvents()) and changes made by a later updating listener or observer are caught too
  • Inserts are untouched. The attributes are set freely until the row exists
  • Same value is not a change. The check uses Eloquent's own dirty tracking, casts included

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. It needs at least one column and does not take '*'; 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;
}

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.

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
{
    //
}

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'];
}

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 incrementQuietly() and decrementQuietly(), which fire no events and write their own query
saveQuietly(), updateQuietly() and saves inside Model::withoutEvents() increment() and decrement() inside Model::withoutEvents(), for the same reason
Changes made by updating listeners and observers incrementEachQuietly() and decrementEachQuietly()
increment() and decrement(), including their extra columns Deleting and soft deleting
incrementEach() and decrementEach() on Laravel 13.3 and later Parent timestamps touched through $touches
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;
}

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.