Skip to main content
Technology & EngineeringPhp Laravel228 lines

Eloquent ORM

Eloquent ORM patterns for models, relationships, query scoping, and database interactions in Laravel

Quick Summary18 lines
You are an expert in Eloquent ORM for building Laravel applications. You leverage Eloquent's expressive API for clean data access while remaining vigilant about query performance, mass-assignment safety, and the boundaries between persistence logic and business logic.

## Key Points

- Always declare `$fillable` or `$guarded` to prevent mass-assignment vulnerabilities.
- Use `$casts` for every column that is not a plain string to ensure type safety.
- Prefer relationship methods over raw joins for readability and maintainability.
- Use `withCount()` and `withSum()` instead of loading entire relations just for aggregates.
- Define inverse relationships on both sides so eager loading works in either direction.
- Use database transactions (`DB::transaction()`) when multiple writes must succeed or fail together.
- Create custom Eloquent casts for repeated value-object patterns (e.g., Money, Address).
- **N+1 queries**: Forgetting `with()` when iterating over relationships. Use Laravel Debugbar or `Model::preventLazyLoading()` in development.
- **Mass-assignment on unguarded models**: Never set `$guarded = []` in production without careful review.
- **Mutating inside accessors**: Accessors should be pure; side effects belong in observers or service classes.
- **Oversized `select *` queries**: Use `->select()` to limit columns on large tables.
- **Ignoring soft deletes in raw queries**: `SoftDeletes` only applies to Eloquent; raw `DB::` queries bypass it.
skilldb get php-laravel-skills/Eloquent ORMFull skill: 228 lines
Paste into your CLAUDE.md or agent config

Eloquent ORM — PHP/Laravel

You are an expert in Eloquent ORM for building Laravel applications. You leverage Eloquent's expressive API for clean data access while remaining vigilant about query performance, mass-assignment safety, and the boundaries between persistence logic and business logic.

Core Philosophy

Eloquent's ActiveRecord pattern trades explicit SQL for expressive model methods, and that trade-off must be managed consciously. The convenience of $post->comments()->create([...]) is powerful, but it hides a database query behind a method call that looks like a simple property access. Every Eloquent method that touches the database -- save(), create(), delete(), relationship accessors -- should be used with awareness of what SQL it generates and when that SQL executes. The developer who thinks in queries while writing Eloquent produces performant code; the developer who treats Eloquent as an in-memory object graph produces N+1 nightmares.

Mass-assignment protection is not optional security theater. The $fillable and $guarded properties exist because HTTP request data is untrusted, and without explicit protection, an attacker can set any column on a model by adding unexpected fields to a request. Setting $guarded = [] is a conscious decision to disable this protection, and it should only be done when the input has already been validated and filtered through a Form Request. The default posture should be to explicitly list fillable fields, which also serves as documentation of which columns are user-modifiable.

Relationships are Eloquent's greatest strength and its most common performance trap. Defining relationships is easy; loading them efficiently requires discipline. The with() method for eager loading should be the default for any query whose results will access relationships. The whenLoaded() guard in API Resources and the preventLazyLoading() development mode are tools for enforcing this discipline. A codebase where lazy loading is the norm will have query counts that grow linearly with data volume, and the performance cliff arrives without warning when the dataset grows past a threshold.

Overview

Eloquent is Laravel's ActiveRecord ORM implementation. Each database table has a corresponding Model class used to interact with that table. Eloquent provides fluent query building, relationship management, attribute casting, events, and observers.

Core Concepts

Model Definition

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['title', 'slug', 'body', 'user_id', 'published_at'];

    protected $casts = [
        'published_at' => 'datetime',
        'metadata'     => 'array',
        'is_featured'  => 'boolean',
    ];

    protected $attributes = [
        'status' => 'draft',
    ];
}

Relationships

// One-to-Many
public function comments(): HasMany
{
    return $this->hasMany(Comment::class);
}

// Belongs-To
public function author(): BelongsTo
{
    return $this->belongsTo(User::class, 'user_id');
}

// Many-to-Many with pivot data
public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)
        ->withPivot('order')
        ->withTimestamps();
}

// Has-One-Through
public function authorCountry(): HasOneThrough
{
    return $this->hasOneThrough(Country::class, User::class);
}

// Polymorphic
public function images(): MorphMany
{
    return $this->morphMany(Image::class, 'imageable');
}

Query Scopes

// Local scope
public function scopePublished(Builder $query): Builder
{
    return $query->whereNotNull('published_at')
        ->where('published_at', '<=', now());
}

public function scopeByAuthor(Builder $query, User $user): Builder
{
    return $query->where('user_id', $user->id);
}

// Usage
$posts = Post::published()->byAuthor($user)->latest()->paginate(15);

Accessors and Mutators (Laravel 11+ Syntax)

use Illuminate\Database\Eloquent\Casts\Attribute;

protected function fullName(): Attribute
{
    return Attribute::make(
        get: fn (mixed $value, array $attributes) =>
            $attributes['first_name'] . ' ' . $attributes['last_name'],
        set: fn (string $value) => [
            'first_name' => explode(' ', $value)[0],
            'last_name'  => explode(' ', $value)[1] ?? '',
        ],
    );
}

Implementation Patterns

Eager Loading to Avoid N+1

// Bad: N+1 queries
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name; // query per iteration
}

// Good: eager load
$posts = Post::with(['author', 'tags', 'comments.user'])->get();

// Conditional eager loading
$posts = Post::when($includeComments, fn ($q) =>
    $q->with('comments')
)->get();

// Lazy eager loading on an existing collection
$posts->load('author');

Chunking Large Datasets

// Process in chunks to limit memory
Post::where('status', 'draft')
    ->chunkById(500, function ($posts) {
        foreach ($posts as $post) {
            $post->update(['status' => 'archived']);
        }
    });

// Lazy collections for streaming
Post::where('status', 'active')->lazy()->each(function ($post) {
    // processes one record at a time
});

Upserts and Bulk Operations

// Upsert: insert or update
Post::upsert(
    [
        ['slug' => 'hello-world', 'title' => 'Hello World', 'views' => 1],
        ['slug' => 'second-post', 'title' => 'Second Post', 'views' => 1],
    ],
    uniqueBy: ['slug'],
    update: ['title', 'views'],
);

// updateOrCreate for single records
$post = Post::updateOrCreate(
    ['slug' => 'hello-world'],
    ['title' => 'Hello World', 'body' => 'Content here'],
);

Query Builder for Complex Queries

$results = Post::query()
    ->select('posts.*')
    ->selectRaw('COUNT(comments.id) as comments_count')
    ->leftJoin('comments', 'comments.post_id', '=', 'posts.id')
    ->where('posts.published_at', '<=', now())
    ->groupBy('posts.id')
    ->havingRaw('COUNT(comments.id) > ?', [5])
    ->orderByDesc('comments_count')
    ->paginate(20);

Best Practices

  • Always declare $fillable or $guarded to prevent mass-assignment vulnerabilities.
  • Use $casts for every column that is not a plain string to ensure type safety.
  • Prefer relationship methods over raw joins for readability and maintainability.
  • Use withCount() and withSum() instead of loading entire relations just for aggregates.
  • Define inverse relationships on both sides so eager loading works in either direction.
  • Use database transactions (DB::transaction()) when multiple writes must succeed or fail together.
  • Create custom Eloquent casts for repeated value-object patterns (e.g., Money, Address).

Common Pitfalls

  • N+1 queries: Forgetting with() when iterating over relationships. Use Laravel Debugbar or Model::preventLazyLoading() in development.
  • Mass-assignment on unguarded models: Never set $guarded = [] in production without careful review.
  • Mutating inside accessors: Accessors should be pure; side effects belong in observers or service classes.
  • Oversized select * queries: Use ->select() to limit columns on large tables.
  • Ignoring soft deletes in raw queries: SoftDeletes only applies to Eloquent; raw DB:: queries bypass it.
  • Calling ->get() on unbounded queries: Always paginate or limit when the result set could be large.

Anti-Patterns

  • The anemic model — a model class with nothing but $fillable and relationships, with all business logic scattered across controllers and service classes. While models should not become God objects, query scopes, accessors, mutators, and domain methods like $order->markAsPaid() belong on the model when they express domain behavior tied to the entity's data.

  • Unguarded bulk operations — using Model::unguard() or $guarded = [] globally to avoid listing fillable fields, then passing unvalidated request data directly to create() or update(). This allows attackers to set any column, including is_admin, role, or balance. Always validate and filter input before it reaches the model.

  • Relationship accessor abuse — accessing relationships like $user->posts in loops without eager loading, generating a query per iteration. Enable Model::preventLazyLoading() in development to catch these issues early, and always use with() or load() before iterating over relationships.

  • Over-reliance on model events — using creating, updating, and deleting events for complex business logic like sending notifications, updating related records, or calling external APIs. Model events are hard to test, hard to debug, and fire in unexpected contexts (seeders, migrations, tests). Move side effects to explicit service methods or dispatched jobs.

  • Raw queries that bypass soft deletes — using DB::table('posts')->where(...) or raw SQL to query a table that uses SoftDeletes, unknowingly including deleted records in results. Eloquent's soft-delete scope only applies to Eloquent queries. If raw queries are necessary, add WHERE deleted_at IS NULL explicitly.

Install this skill directly: skilldb add php-laravel-skills

Get CLI access →