Laravel Advanced: Mastering Eloquent ORM, Relationships & Scopes
🚀 Laravel Advanced: Mastering Eloquent ORM, Relationships & Scopes
📑 Table of Contents
1. Session Objectives
By the end of this session, you will be able to build a scalable blogging data structure (User → Posts ← Category) while mastering industry-standard Eloquent patterns.
- Create related Eloquent models and database tables.
- Define
belongsToandhasManyrelationships. - Identify and prevent the N+1 query problem.
- Create reusable local query scopes and apply global query scopes.
- Create accessors, mutators, and custom attribute casts.
- Test Eloquent features efficiently using Laravel Tinker.
2. Project Setup & Migrations
We begin by generating the Category and Post models alongside their migrations using the -m flag.
php artisan make:model Category -m
php artisan make:model Post -m
2.1 Configure the Categories Migration
// database/migrations/xxxx_xx_xx_create_categories_table.php
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}
2.2 Configure the Posts Migration
// database/migrations/xxxx_xx_xx_create_posts_table.php
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();
});
}
1999) prevents floating-point rounding errors inherent in decimal math.
php artisan migrate
3. Configuring Models & Relationships
Next, we define the relationships and mass-assignment protections in our models.
3.1 The Category Model
// app/Models/Category.php
class Category extends Model
{
protected $fillable = ['name'];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
3.2 The Post Model
// app/Models/Post.php
class Post extends Model
{
protected $fillable = [
'user_id', 'category_id', 'title', 'body', 'price', 'is_published'
];
protected function casts(): array
{
return ['is_published' => 'boolean'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}
3.3 The User Model Update
// app/Models/User.php
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
4. Testing Relationships with Tinker
Laravel Tinker is the perfect sandbox for verifying our database relationships before writing controller logic.
php artisan tinker
# 1. Create a user and categories
$user = User::create(['name' => 'Bob', 'email' => 'bob@example.com', 'password' => bcrypt('password')]);
$laravel = Category::create(['name' => 'Laravel']);
# 2. Create a post manually
$post1 = Post::create([
'user_id' => $user->id,
'category_id' => $laravel->id,
'title' => 'introduction to eloquent',
'body' => 'Eloquent makes database operations easier.',
'price' => 1999,
'is_published' => true,
]);
# 3. Retrieve relationships
$post = Post::first();
$post->user->name; // Returns: "Bob"
$post->category->name; // Returns: "Laravel"
Instead of manually passing
user_id, let Eloquent handle it:
$user->posts()->create([
'category_id' => $category->id,
'title' => 'created through relationship',
// user_id is assigned automatically!
]);
5. Solving the N+1 Query Problem
Lazy loading retrieves relationships only when accessed. In a loop, this causes 1 initial query + N relationship queries (the N+1 problem).
5.1 Fix with Eager Loading
# Load both author and category in just 3 queries total
$posts = Post::with(['user', 'category'])->get();
# Optimize further: select only required columns
$posts = Post::with([
'user:id,name',
'category:id,name',
])->get();
5.2 Count Related Records
$categories = Category::withCount('posts')->get();
foreach ($categories as $category) {
echo $category->name . ': ' . $category->posts_count;
}
5.3 Detect Accidental Lazy Loading
Prevent N+1 bugs during development by adding this to your AppServiceProvider:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
6. Local & Global Query Scopes
Scopes allow you to reuse query constraints cleanly without repeating where clauses.
6.1 Local Query Scopes
Prefix methods with scope in your model. Call them without the prefix.
// 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 (Builder $query, string $keyword) {
$query->where(function (Builder $q) use ($keyword) {
$q->where('title', 'like', "%{$keyword}%")
->orWhere('body', 'like', "%{$keyword}%");
});
});
}
# Usage:
Post::published()->search('Laravel')->latest()->get();
6.2 Global Query Scopes
A global scope applies to every query for a model automatically (like Laravel's built-in Soft Deletes).
# 1. Generate: php artisan make:scope PublishedScope
// app/Models/Scopes/PublishedScope.php
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('is_published', true);
}
}
# 2. Register in Post.php
protected static function booted(): void
{
static::addGlobalScope(new PublishedScope);
}
Post::find($id) may return null if the post is a draft. For admin panels, bypass it using:
Post::withoutGlobalScope(PublishedScope::class)->get()
7. Accessors, Mutators & Custom Casts
Transform data seamlessly when reading from or writing to the database.
7.1 Accessors & Mutators
// app/Models/Post.php
use Illuminate\Database\Eloquent\Casts\Attribute;
# Accessor: Transforms data when READ
protected function title(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
set: fn (string $value) => trim($value) # Mutator: Transforms when SAVED
);
}
# Mutator: Strip HTML tags before saving to DB
protected function body(): Attribute
{
return Attribute::make(
set: fn (string $value) => strip_tags($value)
);
}
7.2 Custom Attribute Casts (Money)
Automatically convert between database integers (cents) and application floats (dollars).
# 1. Generate: php artisan make:cast Money
// app/Casts/Money.php
class Money implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): float
{
return ((int) $value) / 100;
}
public function set(Model $model, string $key, mixed $value, array $attributes): int
{
if (! is_numeric($value)) {
throw new InvalidArgumentException('The money value must be numeric.');
}
return (int) round(((float) $value) * 100);
}
}
# 2. Register in Post.php casts() method:
protected function casts(): array
{
return [
'is_published' => 'boolean',
'price' => Money::class,
];
}
$post->price = 29.95 saves 2995 to the database. Reading $post->price returns 29.95. Format it in Blade with ${{ number_format($post->price, 2) }}.
8. Factories, Seeders & Final Exercises
Automate test data generation while respecting our new custom casts.
8.1 Factory Definition
// database/factories/PostFactory.php
public function definition(): array
{
return [
'user_id' => User::factory(),
'category_id' => Category::factory(),
'title' => fake()->sentence(),
'body' => fake()->paragraphs(3, true),
'price' => fake()->randomFloat(2, 5, 100), # Safe for Money cast!
'is_published' => fake()->boolean(70),
];
}
8.2 Database Seeder
// database/seeders/BlogSeeder.php
public function run(): void
{
$users = User::factory(5)->create();
$categories = collect(['Laravel', 'PHP', 'Database'])
->map(fn (string $name) => Category::create(['name' => $name]));
foreach ($users as $user) {
Post::factory(10)->create([
'user_id' => $user->id,
'category_id' => $categories->random()->id,
]);
}
}
php artisan migrate:fresh --seed
8.3 Useful Tinker Exercises
| Goal | Tinker Command |
|---|---|
| Get Published Posts | Post::published()->get(); |
| Get Drafts (Bypass Global Scope) | Post::withoutGlobalScopes()->draft()->get(); |
| Search by Keyword | Post::search('Laravel')->get(); |
| Count Posts per Category | Category::withCount('posts')->get(); |
| Inspect Raw DB Value | $post->getRawOriginal('price'); |
| Verify Eager Loading | $post->relationLoaded('user'); |
- Always eager load relationships in loops to prevent N+1 queries.
- Use Local Scopes for explicit, reusable query conditions.
- Use Global Scopes sparingly, and always remember how to bypass them for admin views.
- Leverage Custom Casts to keep database storage optimal (e.g., integers for money) while maintaining developer-friendly application logic.