Advanced Eloquent ORM: Relationships, Scopes, Casts & Performance
🚀 Advanced Eloquent ORM: Relationships, Scopes & Casts
📑 Table of Contents
1. Database Schema & Foreign Keys
We are building a simple blogging data structure where Users and Categories have many Posts.
1.1 The Posts Migration
When creating the posts table, we use foreignId() to establish relationships and cascadeOnDelete() to maintain referential integrity.
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();
$table->foreignId('category_id')
->constrained()
->cascadeOnDelete();
$table->string('title');
$table->text('body');
$table->unsignedInteger('price')->default(0);
$table->boolean('is_published')->default(false);
$table->timestamps();
});
}
The
price column stores the smallest currency unit (e.g., cents). $19.99 is stored as 1999. Storing money as an integer avoids floating-point rounding errors common in financial calculations.
2. Defining Eloquent Relationships
Eloquent makes it incredibly intuitive to link models together using hasMany and belongsTo.
2.1 Category & User Models (Has Many)
// app/Models/Category.php
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
2.2 Post Model (Belongs To)
// app/Models/Post.php
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
$user->posts (Property) returns the executed Collection of related models.$user->posts() (Method) returns the Query Builder, allowing you to chain conditions: $user->posts()->where('is_published', true)->get();
3. Conquering the N+1 Query Problem
Lazy loading occurs when a relationship is loaded only when accessed inside a loop. This causes the N+1 Query Problem, devastating database performance.
3.1 The N+1 Problem (Bad)
$posts = Post::all(); // 1 query
foreach ($posts as $post) {
echo $post->user->name; // N queries (1 per post!)
}
3.2 Eager Loading (Good)
Use with() to load relationships in exactly two queries, regardless of how many posts exist.
$posts = Post::with(['user', 'category'])->get();
Add this to your
AppServiceProvider to throw an exception if you accidentally lazy-load during development:Model::preventLazyLoading(! app()->isProduction());
3.3 Counting Relationships
Use withCount() to append a {relation}_count attribute without loading the actual models.
$categories = Category::withCount('posts')->get();
// Access via: $category->posts_count
4. Reusable Query Scopes (Local & Global)
Scopes package reusable query constraints into expressive, chainable methods.
4.1 Local Scopes
// app/Models/Post.php
public function scopePublished(Builder $query): void
{
$query->where('is_published', true);
}
public function scopeSearch(Builder $query, ?string $keyword): void
{
$query->when($keyword, function ($q, $keyword) {
$q->where('title', 'like', "%{$keyword}%");
});
}
Usage: Post::published()->search('Laravel')->get();
4.2 Global Scopes
A global scope automatically applies constraints to every query for a model. Let's hide draft posts everywhere by default.
// app/Models/Scopes/PublishedScope.php
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('is_published', true);
}
}
// Register in Post.php
protected static function booted(): void
{
static::addGlobalScope(new PublishedScope);
}
Because it operates silently,
Post::find($draftId) will return null even if the draft exists! To bypass it, use:Post::withoutGlobalScope(PublishedScope::class)->get();
5. Accessors, Mutators & Custom Casts
Transform data seamlessly as it enters and exits your models.
5.1 Accessors & Mutators
use Illuminate\Database\Eloquent\Casts\Attribute;
// Accessor (Read) & Mutator (Write)
protected function title(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
set: fn (string $value) => trim($value),
);
}
// Mutator only (Sanitize HTML)
protected function body(): Attribute
{
return Attribute::make(
set: fn (string $value) => strip_tags($value),
);
}
5.2 Custom Casts (The Money Cast)
Let's automatically handle the conversion between Dollars (App) and Cents (Database).
// app/Casts/Money.php
class Money implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): float
{
return ((int) $value) / 100; // 1999 -> 19.99
}
public function set(Model $model, string $key, mixed $value, array $attributes): int
{
return (int) round(((float) $value) * 100); // 19.99 -> 1999
}
}
// Register in Post.php
protected function casts(): array
{
return [
'is_published' => 'boolean',
'price' => Money::class,
];
}
6. Factories, Seeders & Demo View
Test your advanced Eloquent setup using Factories and a Blade demonstration route.
6.1 The Demo Route
// routes/web.php
Route::get('/posts-demo', function () {
$posts = Post::with(['user', 'category'])
->latest()
->get(); // Global scope automatically filters out drafts!
return view('posts.demo', compact('posts'));
});
6.2 Blade View Formatting
<!-- resources/views/posts/demo.blade.php -->
<h2>{{ $post->title }}</h2> <!-- Accessor applied automatically -->
<p>Written by {{ $post->user->name }} in {{ $post->category->name }}</p>
<p>{{ $post->body }}</p> <!-- Mutator stripped HTML tags -->
<div>Price: ${{ number_format($post->price, 2) }}</div> <!-- Cast converted cents to dollars -->
7. Troubleshooting Common Errors
| Error / Symptom | Cause & Solution |
|---|---|
| MassAssignmentException | Add the field to the $fillable array in your Model. |
| SQLSTATE: no such table | Run php artisan migrate (or migrate:fresh to rebuild). |
| Foreign Key Constraint Failed | You are assigning a category_id that doesn't exist in the DB. Use an existing ID. |
| Record Not Found (Null) | A Global Scope might be hiding it. Use Post::withoutGlobalScopes()->find($id). |
| Money Value Multiplied Twice | Do not assign 1999 to $post->price. The cast thinks it's $1999.00 and stores 199900. Assign 19.99 instead. |
A well-designed Eloquent model contains more than just table mappings. By utilizing relationships, scopes, accessors, and custom casts, you push domain logic into the model. This keeps your controllers incredibly clean and makes your database queries read like plain English!