brighten / immutable-model
An Eloquent-compatible, read-only model kernel for Laravel 11+
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.5.50|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-28 15:57:19 UTC
README
An Eloquent-compatible, read-only model kernel for Laravel 11+
ImmutableModel provides first-class, enforceable read-only models for Laravel applications. It's perfect for SQL views, read-only tables, denormalized projections, and as a CQRS read-side primitive.
What "Immutable" Means
ImmutableModel enforces database immutability, not strict object immutability:
| Operation | Allowed? | Example |
|---|---|---|
| Read from database | ✅ Yes | User::find(1), User::where(...)->get() |
| In-memory attribute changes | ✅ Yes | $user->computed_field = 'value' |
| Reload from database | ✅ Yes | $user->fresh(), $user->refresh() |
| In-memory copy | ✅ Yes | $user->replicate() (the copy cannot be saved) |
| Database persistence | ❌ Throws | $user->save(), $user->update(), $user->delete() |
| Static write methods | ❌ Throws | User::create(), User::insert() |
This design prevents accidental database writes while remaining compatible with common Laravel patterns like adding computed properties for API responses, serialization, and working with collections.
Guarantees and Limits
ImmutableModel is a guardrail in application code. It is not a database permission system.
Blocked: every SQL write that starts from an immutable model. Each model query runs on a ReadOnlyConnection, which throws on insert, update, delete, and any other write statement. This covers:
- Model methods:
save(),update(),delete(),touch(),increment(), ... - Builder methods:
User::query()->update(),upsert(),truncate(),insertOrIgnoreUsing(), ... - Lower-level builders:
User::query()->toBase()->update(),->getQuery()->delete() - Relations to immutable models:
$post->comments()->create(),$post->tags()->attach(),sync(),updateExistingPivot(), ... - Pivot models of
belongsToMany()to an immutable model, and ofmorphToMany()/morphedByMany()on an immutable model:$tag->pivot->save(),$tag->pivot->delete() - Write methods that future Laravel versions add, because they use the same connection
Not blocked:
$model->getConnection()returns the real connection.$model->getConnection()->table('users')->update(...)writes.- The
DBfacade and ordinary Eloquent models on the same table. - Relations to ordinary (mutable) Eloquent models.
$immutablePost->comments()->create(...)writes ifCommentis a normal Eloquent model. - The pivot model of
morphToMany()/morphedByMany()declared on an ordinary Eloquent model, even when the related model is immutable. Laravel builds that pivot from the parent model, so it is a plainMorphPivoton the real connection. Add->using(ImmutableMorphPivot::class)to the relation to block it. Queries through the relation, such asattach()anddetach(), are still blocked.
Custom pivots: on an immutable model, a pivot class set with ->using(MyPivot::class) must extend ImmutablePivot (or ImmutableMorphPivot for morphToMany()). Any other pivot class throws ImmutableModelConfigurationException when the relation loads.
Pivot models are stricter than models: ImmutablePivot and ImmutableMorphPivot also reject in-memory changes. $tag->pivot->order = 5 and $tag->pivot['order'] = 5 throw ImmutableModelViolationException.
For a hard guarantee, connect with a database user that has read-only permissions.
Why ImmutableModel?
- Enforce architectural boundaries: Prevent accidental database writes at the model level
- Eliminate persistence bugs: Any save/update/delete attempt throws immediately - no silent failures
- Improved performance:
get()+toArray()takes 61-65% less time than Eloquent on MySQL (about 2.8x faster), eager loading up to about 20% faster - Lower memory footprint: about 22% less memory (~1.35 KB vs ~1.73 KB per model)
- Familiar API: Eloquent-compatible read semantics for easy adoption
- Laravel ecosystem compatible: Works with API Resources, serialization, and other common patterns
Installation
composer require brighten/immutable-model
Requirements
- PHP 8.2+ (8.3+ for Laravel 13)
- Laravel 11, 12 or 13
Quick Start
use Brighten\ImmutableModel\ImmutableModel; class UserView extends ImmutableModel { protected $table = 'user_views'; protected $primaryKey = 'id'; protected $casts = [ 'settings' => 'array', 'created_at' => 'datetime', ]; } // Query just like Eloquent $users = UserView::where('active', true)->get(); $user = UserView::find(1); $user = UserView::with('posts')->first(); // In-memory changes are allowed (for computed fields, API responses, etc.) $user->computed_field = 'some value'; // Works fine $user->name = 'Modified'; // Works fine (in-memory only) // But database persistence is blocked $user->save(); // Throws ImmutableModelViolationException $user->update([...]); // Throws ImmutableModelViolationException $user->delete(); // Throws ImmutableModelViolationException UserView::create([...]); // Throws ImmutableModelViolationException
API Reference
Model Configuration
class MyModel extends ImmutableModel { // Required: The database table protected $table = 'my_table'; // Optional: Primary key (null = non-identifiable model) protected $primaryKey = 'id'; // Optional: Database connection (null = default) protected $connection = null; // Optional: Attribute casting protected $casts = [ 'settings' => 'array', 'created_at' => 'datetime', ]; // Optional: Relations to eager load by default protected $with = ['author']; // Optional: Accessors to append to array/JSON output protected $appends = ['full_name']; // Optional: Hidden attributes protected $hidden = ['internal_id']; // Optional: Visible attributes (whitelist) protected $visible = ['id', 'name', 'email']; }
Querying
All standard Eloquent read operations are supported:
// Finding records MyModel::find($id); MyModel::findOrFail($id); MyModel::first(); MyModel::all(); // Where clauses MyModel::where('status', 'active') ->where('created_at', '>', now()->subWeek()) ->orWhere('featured', true) ->whereIn('category_id', [1, 2, 3]) ->whereNotNull('published_at') ->get(); // Ordering & limiting MyModel::orderBy('created_at', 'desc') ->limit(10) ->offset(20) ->get(); // Aggregates MyModel::count(); MyModel::sum('price'); MyModel::avg('rating'); MyModel::max('views');
Relationships
Every Eloquent relationship type works: belongsTo(), hasOne(), hasMany(), hasOneThrough(), hasManyThrough(), belongsToMany(), morphOne(), morphMany(), morphTo(), morphToMany() and morphedByMany(). The related model can be immutable or an ordinary Eloquent model.
class Post extends ImmutableModel { protected $table = 'posts'; public function author() { return $this->belongsTo(User::class, 'user_id', 'id'); } public function comments() { return $this->hasMany(Comment::class, 'post_id', 'id'); } public function featuredImage() { return $this->hasOne(Image::class, 'post_id', 'id'); } } // Eager loading $posts = Post::with('author', 'comments')->get(); // Eager loading with constraints $posts = Post::with(['comments' => fn($q) => $q->where('approved', true)])->get(); // Lazy loading (works, but watch for N+1) $post = Post::find(1); $author = $post->author; // Relation queries $comments = $post->comments()->where('approved', true)->get();
Default foreign keys
ImmutableModel removes an Immutable prefix from the class name when it builds a default foreign key. This lets a read model named after its mutable twin use the same schema:
| Class | Eloquent default | ImmutableModel default |
|---|---|---|
ImmutableUser (table users) |
immutable_user_id |
user_id |
UserView |
user_view_id |
user_view_id |
This applies where Eloquent calls getForeignKey(): hasOne(), hasMany(), the *Through() relations, and the pivot keys of belongsToMany(), morphToMany() and morphedByMany(). belongsTo() is not affected, because Eloquent builds that key from the relation method name. The default pivot table name keeps the prefix. If your columns really are named immutable_user_id, pass the key to the relation explicitly.
Mixing immutable and Eloquent models
A model is writable or not because of its class, not because of how you loaded it. An ordinary Eloquent model stays writable when you reach it through an immutable model, and an immutable model stays read-only when you reach it through an Eloquent model:
$post = ImmutablePost::find(1); $post->meta->first()->save(); // Writes: PostMeta is an ordinary Eloquent model $post->meta()->create([...]); // Writes, for the same reason $category = Category::find(1); // Ordinary Eloquent model $category->posts->first()->save(); // Throws: ImmutablePost is immutable
To make the related side read-only too, point the relation at an immutable model for the same table:
class ImmutablePostMeta extends ImmutableModel { protected $table = 'post_meta'; } class ImmutablePost extends ImmutableModel { public function meta(): HasMany { return $this->hasMany(ImmutablePostMeta::class); // post_id, see "Default foreign keys" } }
Casting
Full Eloquent casting support:
protected $casts = [ // Scalar types 'count' => 'int', 'price' => 'float', 'active' => 'bool', 'name' => 'string', // Date/time 'published_at' => 'datetime', 'birthday' => 'date', 'updated_at' => 'immutable_datetime', 'timestamp' => 'timestamp', // Complex types 'settings' => 'array', 'metadata' => 'json', 'tags' => 'collection', // Custom casters 'address' => AddressCast::class, ];
Custom casters implement Illuminate\Contracts\Database\Eloquent\CastsAttributes as usual. Reads call get(). An in-memory assignment such as $model->address = $value calls set(), like Eloquent. Nothing is written to the database.
Collections
Query results return Laravel's standard Eloquent\Collection. All collection methods work normally - immutability is enforced on database operations, not on in-memory manipulation:
$users = User::all(); // All collection operations work normally $active = $users->filter(fn($u) => $u->active); $sorted = $users->sortBy('name'); $names = $users->pluck('name'); $mapped = $users->map(fn($u) => $u->toArray()); $users->push($newUser); // Works - this is in-memory only $users->transform(fn($u) => $u); // Works // In-memory model changes are allowed $users->first()->name = 'New'; // Works (in-memory only) $users->first()->computed = 'value'; // Works (add computed fields) // But database persistence is blocked $users->first()->save(); // Throws ImmutableModelViolationException $users->first()->delete(); // Throws ImmutableModelViolationException
Pagination
Full pagination support:
$paginated = MyModel::paginate(15); $simple = MyModel::simplePaginate(15); $cursor = MyModel::cursorPaginate(15);
Chunking & Lazy Loading
// Chunk for batch processing MyModel::chunk(1000, function ($models) { foreach ($models as $model) { // Process } }); // Cursor for memory-efficient iteration foreach (MyModel::cursor() as $model) { // Process one at a time }
Global Scopes
Apply query constraints automatically using Laravel's native Scope interface:
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; class TenantScope implements Scope { public function apply(Builder $builder, Model $model): void { $builder->where('tenant_id', auth()->user()->tenant_id); } } class TenantModel extends ImmutableModel { protected static function booted(): void { static::addGlobalScope(new TenantScope); } } // Bypass scopes when needed TenantModel::withoutGlobalScopes()->get(); TenantModel::withoutGlobalScope(TenantScope::class)->get();
Hydration from Raw Data
Create models from existing data without database queries:
// Single model $user = User::fromRow(['id' => 1, 'name' => 'John']); // Collection of models $users = User::fromRows([ ['id' => 1, 'name' => 'John'], ['id' => 2, 'name' => 'Jane'], ]);
Comparison: ImmutableModel vs Eloquent
| Feature | ImmutableModel | Eloquent |
|---|---|---|
| Read queries | Yes | Yes |
| Relationships | Yes | Yes |
| Eager loading | Yes | Yes |
| Attribute casting | Yes | Yes |
| Accessors | Yes | Yes |
| Pagination | Yes | Yes |
| Global scopes | Yes | Yes |
| Write operations | Throws | Yes |
| Dirty tracking | No | Yes |
| Events/Observers | No (observe() and event listeners are ignored) |
Yes |
| Mutators | In memory only | Yes |
| Timestamps | Read as dates; touch() throws |
Yes |
| Mass assignment | In memory only | Yes |
Performance
Measured with benchmarks/read-benchmark.php on MySQL 8.4 (Docker, same machine), PHP 8.4 with OPcache on and JIT off. The test model has a JSON cast, a datetime cast and two timestamps. Times are medians in milliseconds.
Where the speed comes from
- Serialization:
toArray()/ JSON output is where most read time goes. Eloquent formats every date through Carbon'sisoFormat(). ImmutableModel produces the identical string with PHP's native formatter. - Hydration: ImmutableModel builds models without Eloquent's constructor work, such as copying every attribute into
$original. This also saves memory.
Query and serialize (get()->toArray())
| Rows | Eloquent | ImmutableModel | Change |
|---|---|---|---|
| 100 | 6.91 | 2.70 | -61% |
| 1,000 | 74.56 | 26.15 | -65% |
| 10,000 | 761.71 | 274.64 | -64% |
Separate steps
| Rows | Hydration: Eloquent | Hydration: ImmutableModel | toArray(): Eloquent |
toArray(): ImmutableModel |
|---|---|---|---|---|
| 100 | 0.25 | 0.04 | 6.30 | 2.30 |
| 1,000 | 3.04 | 0.78 | 69.52 | 23.38 |
| 10,000 | 36.26 | 18.41 | 706.32 | 237.08 |
Hydration is the time of Model::query()->get() minus DB::table()->get() for the same rows.
Eager loading (10 posts per user)
| Users | Eloquent | ImmutableModel | Change |
|---|---|---|---|
| 10 | 1.03 | 1.07 | within noise |
| 100 | 8.43 | 7.48 | -11% |
| 1,000 | 91.67 | 71.08 | -22% |
Memory (10,000 hydrated models)
| Eloquent | ImmutableModel | Change |
|---|---|---|
| 16.50 MB (1,730 bytes per model) | 12.92 MB (1,354 bytes per model) | -22% |
Run it yourself
docker run -d --name bench-mysql -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=bench -p 33062:3306 mysql:8.4 DB_PORT=33062 php benchmarks/read-benchmark.php seed DB_PORT=33062 composer bench
The database runs on the same machine, so there is no network delay. Over a network, each query takes longer by a fixed amount, which lowers the percentage gain of get(). The absolute time saved stays the same.
Use Cases
ImmutableModel is ideal for:
- SQL Views: Represent database views as read-only models
- Read Replicas: Query read-only database replicas safely
- CQRS Read Models: Enforce read-side immutability in CQRS architectures
- Denormalized Projections: Work with pre-computed, read-only data
- API Responses: Build response data with computed fields, knowing it won't accidentally persist
- Architectural Boundaries: Enforce that certain models are never written to from application code
Not Intended For
- Models that need write operations
- Models using Eloquent events/observers
- Models requiring dirty tracking or automatic timestamp updates
- Drop-in replacement for all Eloquent models
Exceptions
| Exception | When Thrown |
|---|---|
ImmutableModelViolationException |
Any database write attempt (save, update, delete, create, attach, etc.). Blocked SQL is shown with ? placeholders; binding values are never included. |
ImmutableModelConfigurationException |
Invalid configuration: a custom pivot class that does not extend ImmutablePivot / ImmutableMorphPivot |
Contributing
Contributions are welcome! Please ensure all tests pass before submitting a PR:
./vendor/bin/phpunit
License
MIT License. See LICENSE for details.