Laravel Eloquent Relationships: Demystifying One-to-Many and Many-to-One

Published on November 14, 2025
Laravel Eloquent Relationships OneToMany ManyToOne Database

Understanding Database Relationships

Relationships are the heart of relational databases. In Laravel, Eloquent makes working with relationships intuitive and powerful. Let's demystify the two most common relationship types: One-to-Many and Many-to-One.

Quick Overview:

  • One-to-Many: One parent model has many child models
  • Many-to-One: Many child models belong to one parent model
  • They're two sides of the same relationship!

One-to-Many Relationship

What is a One-to-Many Relationship?

A One-to-Many relationship exists when a single model owns multiple instances of another model.

Real-world Examples:

  • A User has many Posts
  • A Category has many Products
  • A Country has many Cities
  • A Blog has many Comments

Database Structure

-- Users table
id | name | email
1  | John | john@example.com
2  | Jane | jane@example.com

-- Posts table  
id | title          | content       | user_id
1  | My First Post  | Hello World   | 1
2  | Another Post   | More content  | 1
3  | Jane's Post    | My content    | 2

Defining the Relationship

In the User Model (One side):

<?php
// app/Models/User.php

namespace App\Models;

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

class User extends Model
{
    /**
     * Get all posts for the user.
     */
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
        
        // If you need custom foreign key:
        // return $this->hasMany(Post::class, 'author_id');
        
        // If you need custom local key:
        // return $this->hasMany(Post::class, 'author_id', 'id');
    }
    
    /**
     * Get only published posts.
     */
    public function publishedPosts(): HasMany
    {
        return $this->hasMany(Post::class)->where('is_published', true);
    }
    
    /**
     * Get posts ordered by creation date.
     */
    public function latestPosts(): HasMany
    {
        return $this->hasMany(Post::class)->latest();
    }
}

In the Post Model (Many side):

<?php
// app/Models/Post.php

namespace App\Models;

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

class Post extends Model
{
    /**
     * Get the user that owns the post.
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
        
        // If you need custom foreign key:
        // return $this->belongsTo(User::class, 'author_id');
        
        // If you need custom owner key:
        // return $this->belongsTo(User::class, 'author_id', 'id');
    }
    
    /**
     * Get the user with only specific columns.
     */
    public function userBasic(): BelongsTo
    {
        return $this->belongsTo(User::class)->select('id', 'name', 'email');
    }
}

Many-to-One Relationship

What is a Many-to-One Relationship?

A Many-to-One relationship exists when many models belong to a single parent model. This is essentially the inverse of a One-to-Many relationship.

Real-world Examples:

  • Many Posts belong to one User
  • Many Products belong to one Category
  • Many Comments belong to one Post
  • Many Employees belong to one Department

The Same Database Structure

-- Same tables as before
-- Users table
id | name | email
1  | John | john@example.com

-- Posts table (many posts belong to one user)  
id | title          | content       | user_id
1  | My First Post  | Hello World   | 1
2  | Another Post   | More content  | 1
3  | Third Post     | More content  | 1

Practical Usage Examples

Basic Relationship Access

From the "One" side (User):

// Get a user with their posts
$user = User::find(1);

// Access posts as a collection
$posts = $user->posts;

// Loop through posts
foreach ($user->posts as $post) {
    echo $post->title;
}

// Count posts
$postCount = $user->posts()->count();

// Get specific post
$firstPost = $user->posts()->first();

From the "Many" side (Post):

// Get a post with its user
$post = Post::find(1);

// Access the user
$user = $post->user;

// Get user attributes
echo $post->user->name;
echo $post->user->email;

// Check if post belongs to specific user
if ($post->user->id === 1) {
    echo "This post belongs to user #1";
}

Creating Related Models

Creating from the "One" side:

$user = User::find(1);

// Method 1: Create and associate
$post = new Post([
    'title' => 'New Post',
    'content' => 'Post content here...'
]);
$user->posts()->save($post);

// Method 2: Create directly
$post = $user->posts()->create([
    'title' => 'Another Post',
    'content' => 'More content here...'
]);

// Method 3: Create multiple posts
$user->posts()->createMany([
    [
        'title' => 'Post One',
        'content' => 'Content one...'
    ],
    [
        'title' => 'Post Two', 
        'content' => 'Content two...'
    ]
]);

Creating from the "Many" side:

// Create post and associate with user
$post = new Post([
    'title' => 'New Post',
    'content' => 'Post content...'
]);

$user = User::find(1);
$post->user()->associate($user);
$post->save();

// Or create with user_id directly
$post = Post::create([
    'title' => 'New Post',
    'content' => 'Post content...',
    'user_id' => 1
]);

Querying with Relationships

Basic Relationship Queries:

// Get users who have posts
$usersWithPosts = User::has('posts')->get();

// Get users with more than 5 posts
$activeUsers = User::has('posts', '>=', 5)->get();

// Get users without any posts
$inactiveUsers = User::doesntHave('posts')->get();

// Get posts with their users (eager loading)
$posts = Post::with('user')->get();

// Get users with their posts count
$users = User::withCount('posts')->get();
foreach ($users as $user) {
    echo "{$user->name} has {$user->posts_count} posts";
}

Advanced Relationship Queries:

// Get posts with specific user conditions
$posts = Post::whereHas('user', function ($query) {
    $query->where('name', 'John')
          ->where('email', 'like', '%@example.com');
})->get();

// Get users with published posts only
$users = User::whereHas('posts', function ($query) {
    $query->where('is_published', true);
})->get();

// Get posts with user and specific columns
$posts = Post::with(['user' => function ($query) {
    $query->select('id', 'name', 'email');
}])->get();

// Order users by their latest post
$users = User::withMax('posts', 'created_at')
            ->orderBy('posts_max_created_at', 'desc')
            ->get();

Real-World Complete Examples

Example 1: Blog System with Users and Posts

Database Migrations:

<?php
// database/migrations/2024_01_15_000001_create_users_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->string('password');
            $table->boolean('is_author')->default(false);
            $table->timestamps();
        });
    }
};
<?php
// database/migrations/2024_01_15_000002_create_posts_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('slug')->unique();
            $table->text('content');
            $table->text('excerpt')->nullable();
            $table->boolean('is_published')->default(false);
            $table->timestamp('published_at')->nullable();
            $table->integer('views')->default(0);
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->timestamps();
            
            // Indexes for performance
            $table->index(['is_published', 'published_at']);
            $table->index('user_id');
        });
    }
};

Complete Models:

<?php
// app/Models/User.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable;

class User extends Model
{
    use HasFactory, Notifiable;

    protected $fillable = [
        'name', 'email', 'password', 'is_author'
    ];

    protected $hidden = [
        'password', 'remember_token',
    ];

    protected $casts = [
        'is_author' => 'boolean',
    ];

    /**
     * Get all posts for the user.
     */
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }

    /**
     * Get only published posts.
     */
    public function publishedPosts(): HasMany
    {
        return $this->posts()->where('is_published', true);
    }

    /**
     * Get the user's latest post.
     */
    public function latestPost()
    {
        return $this->hasOne(Post::class)->latestOfMany();
    }

    /**
     * Get the user's oldest post.
     */
    public function oldestPost()
    {
        return $this->hasOne(Post::class)->oldestOfMany();
    }

    /**
     * Get posts count for dashboard.
     */
    public function getPostsCountAttribute(): int
    {
        return $this->posts()->count();
    }

    /**
     * Get published posts count.
     */
    public function getPublishedPostsCountAttribute(): int
    {
        return $this->publishedPosts()->count();
    }

    /**
     * Check if user can create posts.
     */
    public function canCreatePosts(): bool
    {
        return $this->is_author || $this->posts_count > 0;
    }
}
<?php
// app/Models/Post.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;

class Post extends Model
{
    use HasFactory;

    protected $fillable = [
        'title', 'slug', 'content', 'excerpt', 
        'is_published', 'published_at', 'user_id'
    ];

    protected $casts = [
        'is_published' => 'boolean',
        'published_at' => 'datetime',
    ];

    protected $attributes = [
        'views' => 0,
    ];

    /**
     * The "booted" method of the model.
     */
    protected static function booted(): void
    {
        static::creating(function ($post) {
            if (empty($post->slug)) {
                $post->slug = Str::slug($post->title);
            }
            if (empty($post->excerpt)) {
                $post->excerpt = Str::limit(strip_tags($post->content), 150);
            }
        });

        static::saving(function ($post) {
            // Ensure slug is unique
            $originalSlug = $post->slug;
            $counter = 1;
            
            while (static::where('slug', $post->slug)
                       ->where('id', '!=', $post->id)
                       ->exists()) {
                $post->slug = $originalSlug . '-' . $counter++;
            }
        });
    }

    /**
     * Get the user that owns the post.
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    /**
     * Get the author's name.
     */
    public function getAuthorNameAttribute(): string
    {
        return $this->user->name;
    }

    /**
     * Get reading time in minutes.
     */
    public function getReadingTimeAttribute(): int
    {
        $wordCount = str_word_count(strip_tags($this->content));
        return max(1, ceil($wordCount / 200));
    }

    /**
     * Scope a query to only include published posts.
     */
    public function scopePublished($query)
    {
        return $query->where('is_published', true)
                    ->where('published_at', '<=', now());
    }

    /**
     * Scope a query to only include drafts.
     */
    public function scopeDraft($query)
    {
        return $query->where('is_published', false)
                    ->orWhereNull('published_at');
    }

    /**
     * Publish the post.
     */
    public function publish(): bool
    {
        return $this->update([
            'is_published' => true,
            'published_at' => $this->published_at ?: now(),
        ]);
    }

    /**
     * Unpublish the post.
     */
    public function unpublish(): bool
    {
        return $this->update([
            'is_published' => false,
            'published_at' => null,
        ]);
    }

    /**
     * Increment views count.
     */
    public function incrementViews(): void
    {
        $this->increment('views');
    }
}

Example 2: E-commerce with Categories and Products

Database Structure:

-- Categories table
id | name       | slug
1  | Electronics| electronics
2  | Books      | books

-- Products table
id | name          | price | category_id
1  | iPhone 15     | 999   | 1
2  | MacBook Pro   | 1999  | 1
3  | Laravel Book  | 39    | 2

Model Definitions:

<?php
// app/Models/Category.php

namespace App\Models;

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

class Category extends Model
{
    protected $fillable = ['name', 'slug', 'description'];
    
    /**
     * Get all products for the category.
     */
    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }
    
    /**
     * Get only active products.
     */
    public function activeProducts(): HasMany
    {
        return $this->products()->where('is_active', true);
    }
    
    /**
     * Get featured products.
     */
    public function featuredProducts(): HasMany
    {
        return $this->activeProducts()->where('is_featured', true);
    }
}
<?php
// app/Models/Product.php

namespace App\Models;

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

class Product extends Model
{
    protected $fillable = [
        'name', 'slug', 'description', 'price', 
        'category_id', 'is_active', 'is_featured'
    ];
    
    protected $casts = [
        'price' => 'decimal:2',
        'is_active' => 'boolean',
        'is_featured' => 'boolean',
    ];
    
    /**
     * Get the category that owns the product.
     */
    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }
    
    /**
     * Get category name.
     */
    public function getCategoryNameAttribute(): string
    {
        return $this->category->name;
    }
}

Advanced Relationship Techniques

Eager Loading for Performance

// ❌ BAD: N+1 Query Problem
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name; // Makes a new query for each post
}

// ✅ GOOD: Eager Loading (2 queries total)
$posts = Post::with('user')->get();
foreach ($posts as $post) {
    echo $post->user->name; // No additional queries
}

// Eager load multiple relationships
$posts = Post::with(['user', 'category', 'tags'])->get();

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

// Conditional eager loading
$posts = Post::with(['user' => function ($query) {
    $query->where('is_active', true);
}])->get();

// Lazy eager loading (after initial query)
$posts = Post::all();
$posts->load('user', 'category');

Relationship Methods vs Dynamic Properties

$user = User::find(1);

// Dynamic property (returns Collection)
$posts = $user->posts;

// Relationship method (returns Relation instance)
$postsQuery = $user->posts();

// You can chain query methods on the relationship method
$recentPosts = $user->posts()
                   ->where('created_at', '>', now()->subDays(7))
                   ->orderBy('created_at', 'desc')
                   ->get();

// But NOT on the dynamic property
// ❌ This won't work:
// $recentPosts = $user->posts->where('created_at', '>', now()->subDays(7));

Working with Pivot Data (Intermediate Table)

// If you have additional data in the relationship
class User extends Model
{
    public function teams()
    {
        return $this->belongsToMany(Team::class)
                    ->withPivot('role', 'joined_at')
                    ->withTimestamps();
    }
}

// Access pivot data
$user = User::find(1);
foreach ($user->teams as $team) {
    echo $team->pivot->role;
    echo $team->pivot->joined_at;
}

Common Patterns and Best Practices

Pattern 1: User Dashboard

// Get user with their post statistics
$user = User::withCount([
    'posts',
    'posts as published_posts_count' => function ($query) {
        $query->where('is_published', true);
    },
    'posts as draft_posts_count' => function ($query) {
        $query->where('is_published', false);
    }
])->with(['posts' => function ($query) {
    $query->latest()->limit(5);
}])->find(auth()->id());

// Usage in controller
return view('dashboard', [
    'user' => $user,
    'recentPosts' => $user->posts,
    'stats' => [
        'total_posts' => $user->posts_count,
        'published_posts' => $user->published_posts_count,
        'draft_posts' => $user->draft_posts_count,
    ]
]);

Pattern 2: Category with Products

// Get category with paginated products
$category = Category::with(['products' => function ($query) {
    $query->where('is_active', true)
          ->orderBy('name')
          ->paginate(12);
}])->where('slug', $slug)->firstOrFail();

// Or separate queries for better control
$category = Category::where('slug', $slug)->firstOrFail();
$products = $category->products()
                    ->where('is_active', true)
                    ->orderBy('name')
                    ->paginate(12);

Pattern 3: Bulk Operations

// Update all posts for a user
$user = User::find(1);
$user->posts()->update(['is_featured' => false]);

// Delete all draft posts
$user->posts()->where('is_published', false)->delete();

// Add posts to multiple users (using relationship)
$posts = Post::whereIn('id', [1, 2, 3])->get();
$posts->each->user()->associate($newUser);
$posts->each->save();

Troubleshooting Common Issues

Issue 1: Relationship Not Working

// Check if foreign key matches
// Default: user_id (snake_case of model name + _id)

// If your foreign key is different:
public function posts(): HasMany
{
    return $this->hasMany(Post::class, 'author_id');
}

public function user(): BelongsTo
{
    return $this->belongsTo(User::class, 'author_id');
}

Issue 2: Mass Assignment Protection

// Make sure foreign keys are fillable
protected $fillable = [
    'title', 'content', 'user_id' // Don't forget user_id!
];

// Or use guarded
protected $guarded = [];

Issue 3: Eager Loading Not Working

// Make sure you're using with() before get()
// ❌ This won't eager load:
$posts = Post::all()->with('user');

// ✅ This will eager load:
$posts = Post::with('user')->get();

Common Interview Questions & Answers

1. What's the difference between HasMany and BelongsTo?

HasMany is used on the "one" side of the relationship (User has many Posts), while BelongsTo is used on the "many" side (Post belongs to User). They represent the same relationship from different perspectives.

2. How do you handle custom foreign keys?

Pass the custom foreign key as the second parameter: return $this->hasMany(Post::class, 'author_id') or return $this->belongsTo(User::class, 'author_id').

3. What is eager loading and why is it important?

Eager loading loads related models in a single query to avoid the N+1 query problem, significantly improving performance when accessing relationships in loops.

4. How do you create a related model?

Use $user->posts()->create([...]) from the "one" side, or set the foreign key directly $post->user_id = 1 from the "many" side.

5. What's the difference between $user->posts and $user->posts()?

$user->posts returns a Collection of related models, while $user->posts() returns a Relation instance that you can chain query methods on.

6. How do you count related models efficiently?

Use withCount(): User::withCount('posts')->get() which adds a posts_count attribute without loading all the related models.

Best Practices Summary

  • Always use eager loading when accessing relationships in loops
  • Define relationship return types for better IDE support
  • Use withCount() for counting related models
  • Keep foreign keys consistent with Laravel conventions
  • Use database indexes on foreign key columns
  • Validate relationship data before creating associations
  • Use transactions for complex relationship operations
  • Implement soft deletes carefully with relationships

Now you've mastered One-to-Many and Many-to-One relationships in Laravel Eloquent! In our next post, we'll explore Laravel Eloquent: Mastering Many-to-Many and Polymorphic Relationships to handle more complex relationship scenarios.