brighten / immutable-model
An Eloquent-compatible, read-only model kernel for Laravel 11+
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0
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' |
| 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.
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: 47-74% faster hydration, 25-70% faster eager loading
- Lower memory footprint: ~41% less memory (~1 KB vs ~1.65 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+
- Laravel 11+
Quick Start
use Brighten\ImmutableModel\ImmutableModel; class UserView extends ImmutableModel { protected string $table = 'user_views'; protected ?string $primaryKey = 'id'; protected array $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 string $table = 'my_table'; // Optional: Primary key (null = non-identifiable model) protected ?string $primaryKey = 'id'; // Optional: Database connection (null = default) protected ?string $connection = null; // Optional: Attribute casting protected array $casts = [ 'settings' => 'array', 'created_at' => 'datetime', ]; // Optional: Relations to eager load by default protected array $with = ['author']; // Optional: Accessors to append to array/JSON output protected array $appends = ['full_name']; // Optional: Hidden attributes protected array $hidden = ['internal_id']; // Optional: Visible attributes (whitelist) protected array $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
Supported relationship types:
class Post extends ImmutableModel { protected string $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();
Casting
Full Eloquent casting support:
protected array $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 must implement Illuminate\Contracts\Database\Eloquent\CastsAttributes. Only the get() method is called.
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 | Yes |
| Mutators | No | Yes |
| Timestamps | No | Yes |
| Mass assignment | No | Yes |
Performance
Benchmarks show ImmutableModel is significantly faster for read operations:
Hydration Speed
| Rows | Eloquent | ImmutableModel | Improvement |
|---|---|---|---|
| 100 | 0.30ms | 0.09ms | -70% |
| 1,000 | 3.09ms | 0.80ms | -74% |
| 10,000 | 34.27ms | 9.37ms | -73% |
| 100,000 | 447.29ms | 236.63ms | -47% |
Memory Usage
| Rows | Eloquent | ImmutableModel | Per Model (E) | Per Model (I) | Savings |
|---|---|---|---|---|---|
| 100 | 166 KB | 97 KB | 1.66 KB | 998 B | 41% |
| 1,000 | 1.61 MB | 973 KB | 1.65 KB | 996 B | 41% |
| 10,000 | 16.2 MB | 9.56 MB | 1.66 KB | 1003 B | 41% |
| 100,000 | 161.5 MB | 95.1 MB | 1.65 KB | 997 B | 41% |
Eager Loading (10 posts per user)
| Users | Models | Eloquent | Immutable | Time Δ | Eloquent Mem | Immutable Mem | Mem Δ |
|---|---|---|---|---|---|---|---|
| 10 | 110 | 1.68ms | 1.27ms | -25% | 184 KB | 105 KB | 43% |
| 100 | 1,100 | 5.75ms | 2.23ms | -61% | 1.76 MB | 1.02 MB | 42% |
| 1,000 | 11,000 | 64.22ms | 19.34ms | -70% | 17.53 MB | 10.23 MB | 42% |
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 timestamps
- Drop-in replacement for all Eloquent models
Exceptions
| Exception | When Thrown |
|---|---|
ImmutableModelViolationException |
Any database persistence attempt (save, update, delete, create, etc.) |
ImmutableModelConfigurationException |
Invalid model configuration |
Contributing
Contributions are welcome! Please ensure all tests pass before submitting a PR:
composer test
License
MIT License. See LICENSE for details.