Advanced Eloquent ORM: Relationships, Scopes, Casts & Performance

Advanced Eloquent ORM: Relationships, Scopes, Casts & Performance

🚀 Advanced Eloquent ORM: Relationships, Scopes & Casts

📅 July 28, 2026  •  🎓 3-Day UiTM Workshop  •  👨‍🏫 En. Razi

1. Database Schema & Foreign Keys

We are building a simple blogging data structure where Users and Categories have many Posts.

User │ └── has many Posts Category │ └── has many Posts Post ├── belongs to User └── belongs to Category

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();
    });
}
💰 Why Store Price as an Integer?
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);
}
🧠 Property vs. Method:
$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();
🛡️ Strict Mode Protection:
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);
}
⚠️ Global Scope Risk:
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.
🎯 Key Takeaway:
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!

📘 Advanced Laravel Workshop • UiTM 2026 • Built with ❤️ and Laravel 11

Resources: Eloquent Docs | PHP Sandbox