Introducntion To Eloquent ORM - Types of relationship and pattern

Introducntion To Eloquent ORM - Types of relationship and pattern


1. Introduction to Eloquent ORM

Laravel's Eloquent ORM is an ActiveRecord implementation that lets every database table have a corresponding Model class. Models handle querying, inserting, updating, and deleting records — and they define relationships to other models.

In Laravel 12/13, Eloquent relationships work the same as before but now benefit from:

  • Typed return types on all relationship methods (enforced by IDE tooling)
  • php artisan make:model now generates typed relationship stubs
  • Improved eager loading memory usage
  • Full support for PHP 8.3+ features like typed constants and readonly properties
💡 Convention First Eloquent assumes foreign keys follow the pattern {model_name}_id and primary key is id. You can override both in every relationship method if your schema differs.

Relationship Overview

One to One

User → Profile. One parent record maps to exactly one child record.

One to Many

Post → Comments. One parent has many child records.

Many to Many

User ↔ Roles. Both sides can have many of each other, via a pivot table.

Has One Through

Reach one distant record through an intermediate model.

Has One of Many

Retrieve a single record (latest/oldest/max) from a hasMany set.

Has Many Through

Reach many distant records through an intermediate model.

Polymorphic 1:1

One table serves as child for multiple parent model types.

Polymorphic 1:N

One table serves as the "many" side for multiple parent types.

Polymorphic M:N

A morph pivot table connects multiple model types to a shared model.


2. One to One — hasOne / belongsTo

A one-to-one relationship means one record in Table A is associated with exactly one record in Table B. The foreign key lives on the child table (the "belongs to" side).

Example: A User has one Profile. The profiles table holds the user_id foreign key.

Database Schema

users profiles
id — Primary Key id — Primary Key
name user_id — Foreign Key → users.id
email bio, avatar, phone
created_at, updated_at created_at, updated_at

Migration

PHP — Migration
// database/migrations/xxxx_create_users_table.php
public function up(): void
{
    Schema::create('users', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('email')->unique();
        $table->timestamps();
    });
}

// database/migrations/xxxx_create_profiles_table.php
public function up(): void
{
    Schema::create('profiles', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->text('bio')->nullable();
        $table->string('avatar')->nullable();
        $table->string('phone')->nullable();
        $table->timestamps();
    });
}

Models (Laravel 12/13 typed style)

PHP — app/Models/User.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;

class User extends Model
{
    protected $fillable = ['name', 'email'];

    /**
     * User has ONE profile.
     * FK: profiles.user_id → users.id
     */
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
        // Override keys if needed:
        // return $this->hasOne(Profile::class, 'user_id', 'id');
    }
}
PHP — app/Models/Profile.php
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Profile extends Model
{
    protected $fillable = ['user_id', 'bio', 'avatar', 'phone'];

    /**
     * Profile BELONGS TO one user. (Inverse side)
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Controller — All Operations

PHP — Controller Usage
// ── READ ────────────────────────────────────────────────────
$user = User::find(1);
$profile = $user->profile;        // dynamic property → runs query once
echo $profile->bio;

// Eager load (avoids N+1 problem when looping many users)
$users = User::with('profile')->get();

// ── CREATE ───────────────────────────────────────────────────
$user->profile()->create([
    'bio'    => 'Full-stack Laravel developer',
    'avatar' => 'avatar.png',
]);

// ── UPDATE ───────────────────────────────────────────────────
$user->profile()->update(['bio' => 'Updated bio']);

// ── DELETE ───────────────────────────────────────────────────
$user->profile->delete();

// ── INVERSE: from profile back to user ───────────────────────
$profile = Profile::find(1);
echo $profile->user->name;        // "John Doe"
✅ Best Practice — Always Eager Load When you loop over many users: User::with('profile')->get() runs 2 queries total. Without it: looping over 100 users runs 101 queries vs 2.

3. One to Many — hasMany / belongsTo

One record in the parent table can have many related records in the child table. The foreign key still lives on the child table. This is the most commonly used relationship in Laravel applications.

Example: A Post has many Comments. A User has many Orders.

Database Schema

posts comments
id PK id PK
user_id FK post_id FK → posts.id
title, body user_id FK (commenter)
status body, approved

Migration

PHP — Migration
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->longText('body');
    $table->string('status')->default('draft');
    $table->timestamps();
});

Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('post_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained();
    $table->text('body');
    $table->boolean('approved')->default(false);
    $table->timestamps();
});

Models

PHP — app/Models/Post.php
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Post extends Model
{
    protected $fillable = ['user_id', 'title', 'body', 'status'];

    /** Post HAS MANY comments */
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }

    /** Post BELONGS TO a user (author) */
    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

class Comment extends Model
{
    protected $fillable = ['post_id', 'user_id', 'body', 'approved'];

    /** Comment BELONGS TO one post */
    public function post(): BelongsTo
    {
        return $this->belongsTo(Post::class);
    }
}

Controller Usage

PHP — Controller
// ── READ ─────────────────────────────────────────────────────
$post = Post::find(1);
$comments = $post->comments;          // Collection of Comment models

// Chain conditions using the method (not property)
$approved = $post->comments()
    ->where('approved', true)
    ->latest()
    ->get();

// Count without loading all records
$count = $post->comments()->count();

// Eager load (prevents N+1)
$posts = Post::with('comments')->get();

// Nested eager loading
$posts = Post::with('comments.user')->get();

// ── CREATE ───────────────────────────────────────────────────
$post->comments()->create([
    'user_id' => auth()->id(),
    'body'    => 'Great article!',
]);

// ── whereHas — filter parents by child condition ──────────────
$postsWithApproved = Post::whereHas('comments', function ($q) {
    $q->where('approved', true);
})->get();

// withCount — add comment count to each post
$posts = Post::withCount('comments')->get();
echo $posts[0]->comments_count;

4. Many to Many — belongsToMany

A many-to-many relationship requires a third pivot table that stores the foreign keys of both models. Neither side holds a foreign key directly — they both reference the pivot.

Example: A User can have many Roles. A Role can belong to many Users. The pivot table is role_user.

⚠️ Pivot Table Naming Convention The pivot table name must be the two model names in alphabetical order, singular, snake_case: role_user (not user_role). Laravel derives this automatically. You can override it as the second argument of belongsToMany().

Migration

PHP — Migration
Schema::create('roles', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->timestamps();
});

// Pivot: role_user (alphabetical: r before u)
Schema::create('role_user', function (Blueprint $table) {
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('role_id')->constrained()->cascadeOnDelete();
    $table->primary(['user_id', 'role_id']);   // composite PK
    $table->timestamp('assigned_at')->nullable(); // extra pivot column
});

Models

PHP — Models
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
                    ->withPivot('assigned_at')   // expose extra column
                    ->withTimestamps();            // if pivot has timestamps
    }
}

class Role extends Model
{
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
                    ->withPivot('assigned_at');
    }
}

Controller — Attach, Sync, Detach, Toggle

PHP — Controller
$user = User::find(1);

// ── ATTACH — insert into pivot (does NOT remove existing) ─────
$user->roles()->attach(3);
$user->roles()->attach([1, 2, 3]);
$user->roles()->attach(3, ['assigned_at' => now()]); // with extra pivot data

// ── DETACH — remove from pivot ────────────────────────────────
$user->roles()->detach(3);         // detach one
$user->roles()->detach();           // detach ALL roles

// ── SYNC — replaces ALL current records with given set ────────
$user->roles()->sync([1, 2, 3]);    // user now has only roles 1,2,3

// ── syncWithoutDetaching — add without removing existing ──────
$user->roles()->syncWithoutDetaching([4, 5]);

// ── TOGGLE — attach if missing, detach if present ─────────────
$user->roles()->toggle([1, 3]);

// ── READ — access pivot data ──────────────────────────────────
foreach ($user->roles as $role) {
    echo $role->name;
    echo $role->pivot->assigned_at;  // extra pivot column
}

5. Has One Through — hasOneThrough

hasOneThrough lets you access a single record on a distant model by jumping through an intermediate model. You never manually load the middle model — Eloquent joins through it.

Example: A Mechanic has one Car. That Car has one Owner. So a Mechanic has one Owner through Car.

PHP — app/Models/Mechanic.php
use Illuminate\Database\Eloquent\Relations\HasOneThrough;

class Mechanic extends Model
{
    public function carOwner(): HasOneThrough
    {
        return $this->hasOneThrough(
            Owner::class,     // Final model we want to reach
            Car::class,       // Intermediate model to pass through
            'mechanic_id',    // FK on cars table → mechanics
            'car_id',         // FK on owners table → cars
            'id',             // Local key on mechanics table
            'id'              // Local key on cars table
        );
    }
}

6. Has One of Many — latestOfMany / ofMany

A model can have many related records but you only want one specific record — the latest, oldest, or the one with the max/min value. hasOneOfMany does this in a single efficient query and supports eager loading (unlike calling ->latest()->first() on a hasMany).

PHP — app/Models/User.php
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;

class User extends Model
{
    /** All orders */
    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }

    /** Only the most recent order */
    public function latestOrder(): HasOne
    {
        return $this->hasOne(Order::class)->latestOfMany();
    }

    /** Only the oldest order */
    public function oldestOrder(): HasOne
    {
        return $this->hasOne(Order::class)->oldestOfMany();
    }

    /** Order with the highest total amount */
    public function largestOrder(): HasOne
    {
        return $this->hasOne(Order::class)->ofMany('total', 'max');
    }

    /** Latest shipped order — conditional ofMany */
    public function latestShippedOrder(): HasOne
    {
        return $this->hasOne(Order::class)->ofMany(
            ['created_at' => 'max'],
            fn($query) => $query->where('status', 'shipped')
        );
    }
}
✅ Key Advantage latestOfMany() / ofMany() are proper Eloquent relationships — they support with() eager loading. orders()->latest()->first() is just a query call that cannot be eager-loaded.

7. Has Many Through — hasManyThrough

hasManyThrough provides access to a collection of distant records through an intermediate model. It executes a single JOIN query — you never need to manually load the middle table.

Example: A Country has many Users. Each User has many Posts. Therefore a Country has many Posts through Users.

PHP — app/Models/Country.php
use Illuminate\Database\Eloquent\Relations\HasManyThrough;

class Country extends Model
{
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(
            Post::class,      // Final model — what we want
            User::class,      // Intermediate model — how we get there
            'country_id',     // FK on users table → countries
            'user_id',        // FK on posts table → users
            'id',             // Local key on countries
            'id'              // Local key on users
        );
    }
}

8. Polymorphic One to One — morphOne / morphTo

A polymorphic one-to-one relationship allows a single child table to serve multiple parent model types without creating a separate table for each. Two special morph columns identify which parent model and which ID the record belongs to.

Example: Both User and Post can each have one Image, all stored in a single images table.

Migration

PHP — Migration
Schema::create('images', function (Blueprint $table) {
    $table->id();
    $table->string('url');

    // morphs() creates TWO columns:
    // imageable_id   → unsignedBigInteger
    // imageable_type → string (stores the model class name)
    $table->morphs('imageable');

    $table->timestamps();
});

Models

PHP — Models
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Image extends Model
{
    /** Returns the owning model (User OR Post — resolved dynamically) */
    public function imageable(): MorphTo
    {
        return $this->morphTo();
    }
}

class User extends Model
{
    public function image(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

class Post extends Model
{
    public function image(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }
}

9. Polymorphic One to Many — morphMany / morphTo

A polymorphic one-to-many relationship lets a single child table hold records belonging to many different parent model types. This is the most frequently used polymorphic pattern.

Example: Both Post and Video can have many Comments. All comments live in one comments table.

PHP — Models
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    public function commentable(): MorphTo
    {
        return $this->morphTo();  // resolves to Post or Video dynamically
    }
}

class Post extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

class Video extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

Registering morphMap (Important in Laravel 11/12/13)

PHP — app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Relations\Relation;

public function boot(): void
{
    // Instead of storing "App\Models\Post" in DB, store short alias "post"
    // Makes DB portable if you rename/move model classes
    Relation::morphMap([
        'post'    => Post::class,
        'video'   => Video::class,
        'comment' => Comment::class,
    ]);
}

10. Polymorphic Many to Many — morphToMany / morphedByMany

A polymorphic many-to-many uses a morph pivot table to connect multiple model types to a shared model on a many-to-many basis. One morph pivot table handles all model types.

Example: Both Post and Video can have many Tags. A Tag can belong to many Posts and Videos. All connections stored in one taggables table.

Migration

PHP — Migration
Schema::create('tags', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->timestamps();
});

// Morph pivot table — named taggables (model + 'ables')
Schema::create('taggables', function (Blueprint $table) {
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->morphs('taggable');  // taggable_id + taggable_type
    $table->primary(['tag_id', 'taggable_id', 'taggable_type']);
});

Models

PHP — Models
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\Relations\MorphedByMany;

class Tag extends Model
{
    /** All posts that have this tag */
    public function posts(): MorphedByMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    /** All videos that have this tag */
    public function videos(): MorphedByMany
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

class Post extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

class Video extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

11. Summary Reference Table

Relationship Parent Method Inverse Method Returns FK Lives On Example
One to One hasOne() belongsTo() Single model Child table User → Profile
One to Many hasMany() belongsTo() Collection Child table Post → Comments
Many to Many belongsToMany() belongsToMany() Collection Pivot table User ↔ Roles
Has One Through hasOneThrough() Single model Intermediate + distant Mechanic → Owner via Car
Has One of Many hasOne()->latestOfMany() Single model Child table User → Latest Order
Has Many Through hasManyThrough() Collection Intermediate + distant Country → Posts via Users
Poly One to One morphOne() morphTo() Single model Morph cols on child User/Post → Image
Poly One to Many morphMany() morphTo() Collection Morph cols on child Post/Video → Comments
Poly Many to Many morphToMany() morphedByMany() Collection Morph pivot table Post/Video ↔ Tags

12. Quiz — Test Your Knowledge

🧠 Section A — Basics (One to One & One to Many)

  1. In a hasOne relationship, which table holds the foreign key — the parent or the child table?
  2. What is the difference between $user->profile and $user->profile() in Eloquent?
  3. You have Post hasMany Comment. Write the Eloquent call to get all approved comments ordered newest first, without loading all comments.
  4. What Laravel problem does eager loading (with()) solve? Show lazy vs eager example.
  5. What default foreign key name does Eloquent assume for Post::belongsTo(User::class)?
  6. How do you use withCount() to add a comment count to each post without loading all comments?
  7. What does cascadeOnDelete() do in a migration and when should you use it?

🧠 Section B — Many to Many & Pivot

  1. What naming convention must the pivot table follow for Eloquent to auto-detect it in belongsToMany?
  2. Explain the difference between attach(), sync(), syncWithoutDetaching(), and toggle().
  3. How do you store and read extra columns (e.g. assigned_at) on a pivot table?
  4. Write the full model and migration for a Student that belongs to many Courses, with a pivot column enrolled_at.
  5. If you call $user->roles()->sync([1, 2, 3]) and the user previously had roles [2, 4, 5], what is the final state?
  6. How do you filter users who have at least one role with slug 'admin' using whereHas?

🧠 Section C — Through Relationships & Has One of Many

  1. When would you choose hasManyThrough over manually loading the intermediate model?
  2. What is the key advantage of hasOne()->latestOfMany() over hasMany()->latest()->first()?
  3. Write the model method to get a User's most expensive Order (highest total value).
  4. Explain all 6 parameters of hasManyThrough() in plain English with an example.
  5. Can you use with('latestOrder') for eager loading when defined via hasOne()->latestOfMany()? Why or why not?

🧠 Section D — Polymorphic Relationships

  1. What two database columns does $table->morphs('commentable') create, and what does each store?
  2. What is Relation::morphMap() and why should you always register it in AppServiceProvider::boot()?
  3. Explain the difference between morphOne, morphMany, and morphToMany.
  4. Write the full Tag model with posts() and videos() inverse relationships using morphedByMany.
  5. When should you use a polymorphic relationship instead of creating separate tables for each parent type? Give a real-world scenario.

🧠 Section E — Advanced / Gotchas

  1. What happens if you call $post->comments inside a foreach loop over 100 posts without eager loading?
  2. What is the difference between whereHas('comments') and with('comments')?
  3. Can you chain query builder conditions on a relationship result like $post->comments()->where(...)->get()? Explain why.
  4. How do you eager-load a nested relationship like comments with their authors on a list of posts?
  5. If you define public function latestOrder(): HasOne using latestOfMany(), what class must the return type hint be — HasOne or HasMany?
  6. You have a polymorphic likes table where both Post and Comment can be liked. Write the full migration, Like model, and Post model relationship.

 

Comments (0)

Leave a Comment

FROM CONCEPT TO CREATION

LET's MAKE IT HAPPEN!

I'm available for full-time roles & freelance projects.

I thrive on crafting dynamic web applications, and delivering seamless user experiences.