Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Immutable Attributes Laravel Package

taldres/laravel-immutable-attributes

A Laravel package for defining immutable Eloquent model attributes that can be set once but never changed afterward, helping protect critical values from accidental updates.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm the API surface first. The package is tiny and has near-zero adoption, so spend five minutes in vendor/taldres/laravel-immutable-attributes/src/ before writing code. You want to confirm three things:

  1. The attribute's exact signature (single field string vs. array vs. repeated attribute).
  2. Whether a trait must be added to each model (commonly something like HasImmutableAttributes) or whether a service provider hooks Eloquent globally.
  3. The exception class thrown, so you can catch it deliberately.
composer require taldres/laravel-immutable-attributes

There is likely nothing to publish — no config, no migrations. If the package ships a config file, publish it and read every key; small packages often hide a global on/off switch or an exception-class override there.

Wire it into one model and prove it works. The canonical first use case is identity fields on User:

use Taldres\LaravelImmutableAttributes\Attributes\Immutable;
use Taldres\LaravelImmutableAttributes\HasImmutableAttributes; // confirm name in README

#[Immutable(['uuid', 'email'])]
class User extends Model
{
    use HasImmutableAttributes;
}

Then smoke-test in Tinker before touching production code:

$user = User::factory()->create();          // must succeed
$user->email = '[email protected]';
$user->save();                              // must throw

If that passes, you understand the contract: allowed on create, blocked on update once exists === true.

Implementation Patterns

Treat it as a domain-invariant declaration, not a validation rule. The value proposition is deleting boilerplate like this from every controller, observer, and action class:

if ($model->exists && $model->isDirty('email')) {
    throw new ValidationException(...);
}

Put the invariant on the model once and let every write path — controllers, jobs, console commands, Nova, Filament, imports — hit the same guard.

Good candidates across a typical app:

  • User: uuid, email (if you have a separate change-email flow), referred_by, created_at-adjacent audit columns.
  • Order / Invoice: number, placed_at, currency.
  • OrderLine / ledger entries: unit_price, quantity, account_id — the classic "posted financial rows never mutate" rule.
  • Payment: gateway_reference, amount, captured_at.

Prefer per-model opt-in over a base-model trait. Add the trait to App\Models\BaseModel only if you genuinely want every model guarded; otherwise you pay reflection cost on every save for models that declare nothing. Per-model is clearer in code review, too — seeing #[Immutable] at the top of Invoice documents the rule where readers look.

Fail fast in the request layer when you can. The package throws at save() time, which is correct but late. For user-facing forms, combine it with a FormRequest rule or policy check so the user gets a 422 instead of a 500:

public function authorize(): bool
{
    return ! $this->route('invoice')->isDirty('number');
}

Map the exception to a sane HTTP response. Catch the package exception in bootstrap/app.php (Laravel 11+) or Handler::render() and convert it to a 409 Conflict or 422 if it can be triggered by user input. If it can only be triggered by a developer bug, let it 500 — that's a feature.

Testing patterns. Factories work untouched because exists is false on create. For update tests, assert the throw explicitly:

$this->expectException(ImmutableAttributeException::class);

$invoice = Invoice::factory()->create();
$invoice->update(['number' => 'INV-999']);

Add at least one such test per immutable model to your suite. Since the package has no dependents, your test suite is the compatibility layer for future Laravel upgrades.

Preview before saving when you need custom messaging:

$dirty = collect($invoice->getDirty())->only(['number', 'currency']);

Imports and backfills. If you legitimately need to rewrite an immutable field (schema migration, data fix), the honest approach is to bypass the model entirely — DB::table('invoices')->where(...)->update(...) — and document why in the migration. That's a deliberate, greppable exception rather than a silently disabled guard.

Gotchas and Tips

The guard only covers model-level saves. Model::query()->update(), DB::table()->update(), raw SQL, and any withoutEvents() / saveQuietly() path may bypass it depending on how the package hooks in. Verify which of these are covered — then accept that for genuinely sensitive fields (money, ownership, audit identity) you still want a database trigger, column-level grants, or an append-only table design. This package is a developer-ergonomics guard, not a security boundary.

updateOrCreate is a trap. It looks like an upsert but takes the update branch when the row exists, so changing an immutable field there will throw. Audit existing updateOrCreate calls before rolling this out — they're the most likely source of surprise exceptions in production.

fill() doesn't throw; save() does. Mass assignment, forceFill(), and direct property writes all succeed silently. The failure happens at persistence. Don't assume you've caught the problem because no exception fired during request hydration.

Dirty-tracking semantics matter. Casted values (dates, enums, money objects) are compared with originalIsEquivalent, so setting a field back to its original value should not throw. Test your specific casts — custom casts occasionally compare by identity and produce false positives. Also remember refresh() and syncOriginal() reset the baseline; if you refresh mid-request, previously "changed" values are no longer dirty.

Soft deletes, restores, and replicate() are unaffected in the expected design — restore touches deleted_at, and replicate() produces a new model with exists === false. Confirm replicate()->save() works if you use duplication flows.

Watch the reflection cost on hot paths. Attribute discovery may happen per-save. If the package caches the immutable list per class, you're fine; if not, and you write thousands of rows per request, benchmark it. A 10-line static array cache in an overridden method is a cheap fix.

Maturity caveats. Zero stars, zero dependents, and a future-dated last release (2026-10-15) mean you are the test pilot. Pin an exact version in composer.json, read the entire source (it's small), and consider vendoring the check into your own trait if the package's hook point is fragile. You want a diffable, owned copy more than you want the dependency.

Extension points worth checking: whether the exception class is configurable, whether there's a hook to allow changes under specific conditions (e.g., an admin flag, a forceImmutable() toggle), and whether the attribute can target methods or accessors. If no escape hatch exists, write one in your own trait rather than forking — a withoutImmutableGuard(fn () => ...) wrapper around withoutEvents() is usually enough for migrations and seeders.

Don't confuse this with authorization. It answers "may this column ever change?" not "may this user change it?" Keep policy checks separate and let this be the last line of defense against accidental mutation.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo