Laravel Eloquent Relationships

Laravel Eloquent Relationships Tutorial Blog System

Laravel Eloquent relationships let us define how our database tables are linked, using simple PHP methods instead of complex SQL. They work like bridges between models, making it easy to get related data.

In this guide, we’ll build a blog system to learn all types of relationships from one-to-one to advanced polymorphic ones.

Setting Up the Project Environment

Let’s start by creating a fresh Laravel project that will serve as our foundation for exploring relationships and collections:

composer create-project laravel/laravel eloquent-relationships
cd eloquent-relationships
cp .env.example .env
php artisan key:generate

Configure the database connection in the .env file:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=eloquent_relationships
DB_USERNAME=root
DB_PASSWORD=your_password

Now we’ll create the models and migrations for our blog system. This system will demonstrate various relationship types through realistic scenarios:

php artisan make:model User -m
php artisan make:model Profile -m
php artisan make:model Post -m
php artisan make:model Comment -m
php artisan make:model Tag -m
php artisan make:model Category -m
php artisan make:model Image -m
php artisan make:model Country -m

php artisan make:migration create_post_tag_table

Understanding Laravel Eloquent Relationships

Before diving into code, it’s essential to understand the different types of relationships and their real-world applications:

One-to-One: Each record in table A relates to exactly one record in table B, and vice versa. Example: User ↔ Profile (each user has one profile).

One-to-Many: Each record in table A can relate to multiple records in table B, but each record in table B belongs to only one record in table A. Example: UserPosts (one user can write many posts).

Many-to-Many: Records in table A can relate to multiple records in table B, and vice versa. Example: PostsTags (posts can have multiple tags, tags can belong to multiple posts).

Has One Through: Access a distant relationship through an intermediate model. Example: CountryUserProfile (get country’s user profiles).

Has Many Through: Like Has One Through, but for multiple records. Example: CountryUsersPosts (get all posts from a country’s users).

Polymorphic: A model can belong to multiple other model types on a single association. Example: Images can belong to either Users or Posts.

One-to-One Relationships

One-to-one relationships connect two models where each record in one table corresponds to exactly one record in another table. Let’s implement a User-Profile relationship where each user has one profile containing additional information.

First, let’s set up our User model and migration:

class User extends Authenticatable
{
    use HasFactory, Notifiable;

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

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

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
    }

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}

The hasOne() method establishes the one-to-one relationship from the User side. Laravel automatically assumes the foreign key will be user_id in the profiles table and will reference the id column in the users table.

Now let’s create the Profile model that represents the “belongs to” side of this relationship:

class Profile extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'bio',
        'website',
        'location',
        'birth_date',
        'avatar_path',
    ];

    protected function casts(): array
    {
        return [
            'birth_date' => 'date',
        ];
    }

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

The belongsTo() method defines the inverse relationship, allowing us to access the user from a profile instance. Laravel automatically determines that this relationship uses user_id as the foreign key.

The migration files define our database structure:

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->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

The profiles migration includes the foreign key constraint:

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('profiles', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->text('bio')->nullable();
            $table->string('website')->nullable();
            $table->string('location')->nullable();
            $table->date('birth_date')->nullable();
            $table->string('avatar_path')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('profiles');
    }
};

The foreignId('user_id')->constrained() creates a foreign key constraint that automatically references the id column on the users table.

The onDelete('cascade') ensures that when a user is deleted, their profile is automatically deleted as well.

Working with One-to-One Relationships

Once the relationships are defined, Laravel provides several elegant ways to create and work with related data.

Creating a profile through the relationship is the most common approach:

$user = User::create([
    'name' => 'John Doe',
    'email' => 'john@example.com',
    'password' => bcrypt('password'),
]);

$profile = $user->profile()->create([
    'bio' => 'Full-stack developer with 5 years experience',
    'website' => 'https://johndoe.dev',
    'location' => 'New York, NY',
    'birth_date' => '1990-05-15',
]);

This approach automatically sets the user_id foreign key for us. When we call profile() as a method (with parentheses), we get a query builder that allows us to create, update, or query related records.

Alternatively, we can create the profile separately and then associate it:

$profile = new Profile([
    'bio' => 'Another bio',
    'website' => 'https://example.com',
    'location' => 'California',
]);

$user->profile()->save($profile);

The save() method automatically sets the foreign key and saves the model to the database.

Accessing related data is intuitive and follows Laravel’s conventions:

$user = User::find(1);
$bio = $user->profile->bio;
$website = $user->profile->website;

Notice that we access profile as a property (without parentheses) to get the actual related model. Laravel automatically executes the query when we first access the property.

We can also traverse the relationship in reverse:

$profile = Profile::find(1);
$userName = $profile->user->name;
$userEmail = $profile->user->email;

Checking if a relationship exists prevents null pointer errors:

if ($user->profile) {
    echo "User has a profile: " . $user->profile->bio;
} else {
    echo "User has no profile";
}

Updating related records can be done directly through the relationship:

$user->profile()->update([
    'bio' => 'Updated bio information',
    'location' => 'Updated location',
]);

This finds the related profile and updates it in one operation.

One-to-Many Relationships

One-to-many relationships are the most common type, where one model can have multiple related records, but each related record belongs to only one parent. In our blog system, we’ll implement this through Users and Posts (one user can write many posts) and Posts and Comments (one post can have many comments).

Let’s start with the Post model:

class Post extends Model
{
    use HasFactory;

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

    protected function casts(): array
    {
        return [
            'published_at' => 'datetime',
        ];
    }

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

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

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

    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class)->withTimestamps();
    }

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

    public function scopePublished($query)
    {
        return $query->where('status', 'published')
                    ->whereNotNull('published_at')
                    ->where('published_at', '<=', now());
    }

    public function scopeDraft($query)
    {
        return $query->where('status', 'draft');
    }
}

The Post model demonstrates several relationship types. The comments() method uses hasMany() to indicate that one post can have multiple comments. The query scopes (scopePublished and scopeDraft) provide convenient ways to filter posts by status.

Now let’s examine the Comment model which shows both sides of one-to-many relationships:

class Comment extends Model
{
    use HasFactory;

    protected $fillable = [
        'post_id',
        'user_id',
        'parent_id',
        'content',
        'status',
    ];

    public function post(): BelongsTo
    {
        return $this->belongsTo(Post::class);
    }

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

    public function parent(): BelongsTo
    {
        return $this->belongsTo(Comment::class, 'parent_id');
    }

    public function replies(): HasMany
    {
        return $this->hasMany(Comment::class, 'parent_id');
    }

    public function scopeApproved($query)
    {
        return $query->where('status', 'approved');
    }
}

The Comment model includes a self-referencing relationship where comments can have parent comments (for reply functionality). The parent() and replies() methods demonstrate how to work with hierarchical data structures.

The migration files establish the database structure:

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->foreignId('category_id')->nullable()->constrained()->onDelete('set null');
            $table->string('title');
            $table->string('slug')->unique();
            $table->longText('content');
            $table->text('excerpt')->nullable();
            $table->timestamp('published_at')->nullable();
            $table->enum('status', ['draft', 'published', 'archived'])->default('draft');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

The posts table includes foreign keys for both users and categories. Notice how the category relationship uses onDelete('set null') instead of cascade, preserving posts even when categories are deleted.

The comments migration demonstrates the self-referencing foreign key:

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->foreignId('post_id')->constrained()->onDelete('cascade');
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->foreignId('parent_id')->nullable()->constrained('comments')->onDelete('cascade');
            $table->text('content');
            $table->enum('status', ['pending', 'approved', 'rejected'])->default('pending');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('comments');
    }
};

The parent_id foreign key references the same table, enabling nested comment structures.

Working with One-to-Many Relationships

Laravel provides several methods for creating and managing one-to-many relationships.

Creating posts through the user relationship automatically sets the foreign key:

$user = User::find(1);

$post = $user->posts()->create([
    'title' => 'My First Blog Post',
    'slug' => 'my-first-blog-post',
    'content' => 'This is the content of my first blog post...',
    'excerpt' => 'A brief excerpt...',
    'status' => 'published',
    'published_at' => now(),
]);

The create() method builds a new Post model, sets the user_id automatically, and saves it to the database.

We can also create a model instance first and then associate it:

$post = new Post([
    'title' => 'Another Post',
    'slug' => 'another-post',
    'content' => 'Content here...',
]);

$user->posts()->save($post);

The save() method sets the foreign key and persists the model.

For existing records, use the save() method to change ownership:

$existingPost = Post::find(2);
$user->posts()->save($existingPost);

This updates the post’s user_id to associate it with the current user.

Creating nested relationships like comments on posts follows the same pattern:

$post = Post::find(1);

$comment = $post->comments()->create([
    'user_id' => 2,
    'content' => 'Great post! Very informative.',
    'status' => 'approved',
]);

For reply functionality, create a comment with a parent:

$parentComment = Comment::find(1);

$reply = $parentComment->replies()->create([
    'post_id' => $parentComment->post_id,
    'user_id' => 3,
    'content' => 'I agree with this comment!',
    'status' => 'approved',
]);

Notice how we need to explicitly set the post_id since the reply relationship only handles the parent-child connection.

Retrieving related data returns Laravel Collections:

$user = User::find(1);
$userPosts = $user->posts;
$publishedPosts = $user->posts()->published()->get();

When we access posts as a property, we get all related posts. When we call posts() as a method, we get a query builder that allows further filtering.

Counting related records is efficient and doesn’t load the actual data:

$postCount = $user->posts()->count();
$publishedCount = $user->posts()->published()->count();

Checking for existence prevents unnecessary data loading:

$hasComments = $post->comments()->exists();
$hasApprovedComments = $post->comments()->approved()->exists();

Bulk operations work seamlessly with relationships:

$user->posts()->where('status', 'draft')->update(['status' => 'archived']);
$user->posts()->where('created_at', '<', now()->subYear())->delete();

These operations update or delete multiple related records based on conditions.

Many-to-Many Relationships

Many-to-many relationships allow records from two tables to be associated with multiple records in each other’s table. This requires an intermediate “pivot” table to store the associations. In our blog system, we’ll implement this through Posts and Tags, where posts can have multiple tags and tags can belong to multiple posts.

Let’s start with the Tag model:

class Tag extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'slug',
        'description',
        'color',
    ];

    public function posts(): BelongsToMany
    {
        return $this->belongsToMany(Post::class)
                    ->withTimestamps()
                    ->withPivot('created_by', 'featured');
    }

    public function scopePopular($query, $limit = 10)
    {
        return $query->withCount('posts')
                    ->orderBy('posts_count', 'desc')
                    ->limit($limit);
    }
}

The belongsToMany() method establishes the many-to-many relationship. The withTimestamps() method tells Laravel to automatically maintain created_at and updated_at columns in the pivot table. The withPivot() method specifies additional columns in the pivot table that we want to access.

Let’s also create a Category model to demonstrate another type of relationship:

class Category extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'slug',
        'description',
        'parent_id',
    ];

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }

    public function parent(): BelongsTo
    {
        return $this->belongsTo(Category::class, 'parent_id');
    }

    public function children(): HasMany
    {
        return $this->hasMany(Category::class, 'parent_id');
    }
}

The Category model demonstrates self-referencing relationships where categories can have parent categories and child categories, creating a hierarchical structure.

The migration files establish the database structure:

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('tags', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->string('color', 7)->default('#6366f1'); // Hex color
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tags');
    }
};

The tags table includes a color field for visual representation in the user interface.

The categories migration shows the self-referencing structure:

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->foreignId('parent_id')->nullable()->constrained('categories')->onDelete('cascade');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};

The crucial part is the pivot table migration:

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('post_tag', function (Blueprint $table) {
            $table->id();
            $table->foreignId('post_id')->constrained()->onDelete('cascade');
            $table->foreignId('tag_id')->constrained()->onDelete('cascade');
            $table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
            $table->boolean('featured')->default(false);
            $table->timestamps();
            
            // Ensure unique combinations
            $table->unique(['post_id', 'tag_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('post_tag');
    }
};

The pivot table includes the standard post_id and tag_id foreign keys, plus additional columns like created_by and featured that provide extra metadata about the relationship. The unique constraint ensures that a post can’t be associated with the same tag multiple times.

Working with Many-to-Many Relationships

Many-to-many relationships offer rich functionality for managing associations between models.

First, let’s create some tags to work with:

$tag1 = Tag::create(['name' => 'Laravel', 'slug' => 'laravel', 'color' => '#f56565']);
$tag2 = Tag::create(['name' => 'PHP', 'slug' => 'php', 'color' => '#38b2ac']);
$tag3 = Tag::create(['name' => 'Web Development', 'slug' => 'web-development', 'color' => '#805ad5']);

The attach() method adds new associations to the pivot table:

$post = Post::find(1);
$post->tags()->attach([1, 2, 3]);

This creates three new rows in the pivot table, associating the post with all three tags.

We can include additional pivot data when attaching:

$post->tags()->attach(1, ['created_by' => auth()->id(), 'featured' => true]);

For multiple attachments with different pivot data:

$post->tags()->attach([
    1 => ['created_by' => auth()->id(), 'featured' => true],
    2 => ['created_by' => auth()->id(), 'featured' => false],
]);

The sync() method replaces all existing associations:

$post->tags()->sync([1, 2, 3]);

This removes any existing tag associations and creates new ones for the specified tags. It’s perfect for update operations where we want to replace the current selection.

We can also sync with pivot data:

$post->tags()->sync([
    1 => ['created_by' => auth()->id(), 'featured' => true],
    2 => ['created_by' => auth()->id()],
]);

The syncWithoutDetaching() method only adds new associations without removing existing ones:

$post->tags()->syncWithoutDetaching([4, 5]);

The detach() method removes associations:

$post->tags()->detach();
$post->tags()->detach([1, 2]);
$post->tags()->detach(1);

The first call removes all tag associations, the second removes specific tags, and the third removes a single tag.

The toggle() method is particularly useful for interfaces where users can select/deselect items:

$post->tags()->toggle([1, 2, 3]);

This attaches tags that aren’t currently associated and detaches tags that are already associated.

Retrieving many-to-many data returns collections as expected:

$post = Post::find(1);
$tags = $post->tags;

Accessing pivot data requires iterating through the collection:

foreach ($post->tags as $tag) {
    echo $tag->name;
    echo $tag->pivot->created_at;
    echo $tag->pivot->featured;
    echo $tag->pivot->created_by;
}

The pivot property contains all the additional data from the pivot table.

We can query relationships with pivot conditions:

$featuredTags = $post->tags()->wherePivot('featured', true)->get();
$recentTags = $post->tags()->wherePivotBetween('created_at', [$startDate, $endDate])->get();

These queries filter the relationship based on pivot table values.

The popular tags scope demonstrates how to work with relationship counts:

$popularTags = Tag::popular(5)->get();

foreach ($popularTags as $tag) {
    echo "{$tag->name}: {$tag->posts_count} posts";
}

This query includes the post count for each tag and orders by popularity.

Has One Through & Has Many Through

Through” relationships provide a convenient way to access distant relationships via an intermediate model. These relationships are invaluable when we need to reach across multiple levels of associations. Let’s implement CountriesUsersPosts and CountriesUsersProfiles to demonstrate this concept.

First, let’s create the Country model:

class Country extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'code',
        'continent',
    ];

    public function users(): HasMany
    {
        return $this->hasMany(User::class);
    }

    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, User::class);
    }

    public function comments(): HasManyThrough
    {
        return $this->hasManyThrough(Comment::class, User::class);
    }

    public function publishedPosts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, User::class)
                    ->where('posts.status', 'published');
    }

    public function userProfiles(): HasManyThrough
    {
        return $this->hasManyThrough(
            Profile::class,
            User::class,
            'country_id', 
            'user_id',
            'id',         // Local key on countries table
            'id'          // Local key on users table
        );
    }
}

The hasManyThrough() method establishes relationships that span multiple tables. The basic syntax uses Laravel’s conventions to determine foreign keys, while the more explicit version allows us to specify exactly which keys to use for the relationship.

We need to update our User model to include the country relationship:

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

class User extends Authenticatable
{
    // ... existing code ...

    protected $fillable = [
        'name',
        'email',
        'password',
        'country_id', // Add this
    ];

    // User belongs to Country
    public function country(): BelongsTo
    {
        return $this->belongsTo(Country::class);
    }

    // ... rest of existing relationships ...
}

The migration files establish the database structure:

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('countries', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('code', 2)->unique(); // ISO country code
            $table->string('continent');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('countries');
    }
};

We also need to add the country reference to the users table:

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->foreignId('country_id')->nullable()->after('email')->constrained()->onDelete('set null');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropForeign(['country_id']);
            $table->dropColumn('country_id');
        });
    }
};

Working with Through Relationships

Through relationships enable powerful queries that span multiple table connections.

Let’s set up some sample data to demonstrate these relationships:

$usa = Country::create([
    'name' => 'United States', 
    'code' => 'US', 
    'continent' => 'North America'
]);

$canada = Country::create([
    'name' => 'Canada', 
    'code' => 'CA', 
    'continent' => 'North America'
]);

$user1 = User::create([
    'name' => 'John Doe',
    'email' => 'john@example.com',
    'password' => bcrypt('password'),
    'country_id' => $usa->id,
]);

$user2 = User::create([
    'name' => 'Jane Smith',
    'email' => 'jane@example.com',
    'password' => bcrypt('password'),
    'country_id' => $canada->id,
]);

Now our users can create posts that will be accessible through their country:

$user1->posts()->create([
    'title' => 'Post from USA',
    'slug' => 'post-from-usa',
    'content' => 'This is a post from the United States',
    'status' => 'published',
    'published_at' => now(),
]);

$user2->posts()->create([
    'title' => 'Post from Canada',
    'slug' => 'post-from-canada',
    'content' => 'This is a post from Canada',
    'status' => 'published',
    'published_at' => now(),
]);

Now we can access posts from a country through the user relationship:

$usa = Country::find(1);
$usaPosts = $usa->posts;
$publishedPosts = $usa->publishedPosts;

The through relationship automatically joins the countries, users, and posts tables to retrieve all posts created by users from the specified country.

Counting related records through intermediate models is straightforward:

$postCount = $usa->posts()->count();
$publishedCount = $usa->publishedPosts()->count();

We can add additional constraints to through relationships:

$recentPosts = $usa->posts()->where('posts.created_at', '>', now()->subMonth())->get();

Notice the table prefix posts. which is necessary when the column name might be ambiguous across the joined tables.

Accessing intermediate model data remains available:

foreach ($usa->posts as $post) {
    echo "Post: {$post->title}";
    echo "Author: {$post->user->name}";
    echo "Country: {$post->user->country->name}";
}

Even though we accessed posts through the country, we can still reach back to get user information.

Getting statistics across relationships becomes powerful with through relationships:

$countriesWithMostPosts = Country::withCount('posts')
    ->orderBy('posts_count', 'desc')
    ->take(5)
    ->get();

foreach ($countriesWithMostPosts as $country) {
    echo "{$country->name}: {$country->posts_count} posts";
}

This query counts posts for each country and returns the top 5 most active countries.

Polymorphic Relationships

Polymorphic relationships allow a model to belong to more than one other model on a single association. This is useful when we want to share functionality across different models. Let’s implement an Image model that can belong to either Users (as avatars) or Posts (as featured images).

Let’s create the Image model:

class Image extends Model
{
    use HasFactory;

    protected $fillable = [
        'imageable_id',
        'imageable_type',
        'filename',
        'original_name',
        'mime_type',
        'size',
        'alt_text',
        'caption',
    ];

    public function imageable(): MorphTo
    {
        return $this->morphTo();
    }

    public function getUrlAttribute(): string
    {
        return asset('storage/images/' . $this->filename);
    }

    public function scopeForModel($query, $modelType)
    {
        return $query->where('imageable_type', $modelType);
    }
}

The morphTo() method creates the polymorphic relationship. Laravel uses the imageable_id and imageable_type columns to determine which model and record the image belongs to. The accessor getUrlAttribute() provides a convenient way to get the full image URL.

Now we need to update our existing models to include the polymorphic relationships.

Update the User model:

class User extends Authenticatable
{
    // ... existing code ...

    // Polymorphic One-to-One: User has one Image (avatar)
    public function avatar(): MorphOne
    {
        return $this->morphOne(Image::class, 'imageable');
    }

    // Polymorphic One-to-Many: User has many Images
    public function images(): MorphMany
    {
        return $this->morphMany(Image::class, 'imageable');
    }
}

The User model now has both morphOne() for a single avatar image and morphMany() for multiple images if needed.

The Post model was already updated in our earlier example to include:

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

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

The migration for the polymorphic relationship uses Laravel’s morphs() helper:

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('images', function (Blueprint $table) {
            $table->id();
            $table->morphs('imageable');
            $table->string('filename');
            $table->string('original_name');
            $table->string('mime_type');
            $table->unsignedInteger('size');
            $table->string('alt_text')->nullable();
            $table->text('caption')->nullable();
            $table->timestamps();

            $table->index(['imageable_type', 'imageable_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('images');
    }
};

The morphs('imageable') method creates both imageable_id and imageable_type columns. The index improves query performance when searching by the polymorphic relationship.

Working with Polymorphic Relationships

Polymorphic relationships provide flexible ways to associate shared functionality across different model types.

Creating a user avatar demonstrates the basic polymorphic creation:

$user = User::find(1);

$avatar = $user->avatar()->create([
    'filename' => 'user-1-avatar.jpg',
    'original_name' => 'my-photo.jpg',
    'mime_type' => 'image/jpeg',
    'size' => 245760,
    'alt_text' => 'User profile picture',
]);

Laravel automatically sets both imageable_id to the user’s ID and imageable_type to 'App\Models\User'.

Creating a featured image for a post works identically:

$post = Post::find(1);

$featuredImage = $post->featuredImage()->create([
    'filename' => 'post-1-featured.jpg',
    'original_name' => 'blog-hero.jpg',
    'mime_type' => 'image/jpeg',
    'size' => 512000,
    'alt_text' => 'Blog post featured image',
    'caption' => 'This image represents the main topic of the blog post',
]);

For multiple images like a gallery, use createMany():

$post->images()->createMany([
    [
        'filename' => 'gallery-1.jpg',
        'original_name' => 'image1.jpg',
        'mime_type' => 'image/jpeg',
        'size' => 128000,
        'alt_text' => 'Gallery image 1',
    ],
    [
        'filename' => 'gallery-2.jpg',
        'original_name' => 'image2.jpg',
        'mime_type' => 'image/jpeg',
        'size' => 156000,
        'alt_text' => 'Gallery image 2',
    ],
]);

This creates multiple image records in a single operation, all properly associated with the post.

Retrieving polymorphic relationships follows the same patterns as other relationships:

$user = User::find(1);

if ($user->avatar) {
    echo "Avatar URL: " . $user->avatar->url;
    echo "Alt text: " . $user->avatar->alt_text;
}

$allUserImages = $user->images;

Accessing the parent model from a polymorphic child is where the magic happens:

$image = Image::find(1);
$owner = $image->imageable;

The imageable property returns the actual parent model, whether it’s a User or Post.

We can check the type of the parent model:

if ($image->imageable instanceof User) {
    echo "This image belongs to user: " . $image->imageable->name;
} elseif ($image->imageable instanceof Post) {
    echo "This image belongs to post: " . $image->imageable->title;
}

This allows us to handle different parent types appropriately in our application logic.

Querying images by parent type uses the scope we defined:

$userImages = Image::forModel(User::class)->get();
$postImages = Image::forModel(Post::class)->get();

For more complex queries, we can eager load the polymorphic relationships:

$imagesWithOwners = Image::with('imageable')->get();

foreach ($imagesWithOwners as $image) {
    echo "Image: {$image->filename}";
    echo "Owner type: {$image->imageable_type}";
    
    if ($image->imageable) {
        $ownerName = $image->imageable instanceof User 
            ? $image->imageable->name 
            : $image->imageable->title;
        echo "Owner: {$ownerName}";
    }
}

Getting statistics about polymorphic relationships:

$userImageCount = Image::where('imageable_type', User::class)->count();
$postImageCount = Image::where('imageable_type', Post::class)->count();

Eager Loading & N+1 Problem

The N+1 query problem is one of the most common performance issues in applications using ORMs. It occurs when we load a collection of models and then access a related model for each item, causing one query for the initial collection plus N additional queries for each related model.

Understanding the N+1 Problem

Here’s how the N+1 problem manifests in practice:

$posts = Post::all();

foreach ($posts as $post) {
    echo $post->user->name;
}

The first line executes one query to get all posts. Then, for each of the 10 posts (assuming we have 10), Laravel executes another query to get the user information, resulting in 11 total queries instead of just 2.

Solving with Eager Loading

Eager loading solves this problem by loading relationships in advance:

$posts = Post::with('user')->get();

foreach ($posts as $post) {
    echo $post->user->name;
}

This version executes only 2 queries: one for all posts and one for all related users. Laravel automatically matches users to posts based on the foreign keys.

Loading multiple relationships at once:

$posts = Post::with(['user', 'category', 'tags'])->get();

This loads the post data along with user, category, and tag information in just 4 queries total, regardless of how many posts we have.

Nested relationships allow us to load relationships of relationships:

$posts = Post::with(['user.profile', 'user.country'])->get();

This loads posts, their users, and each user’s profile and country information.

Deep nesting works for complex relationship chains:

$posts = Post::with(['comments.user.profile'])->get();

This loads posts, their comments, each comment’s user, and each user’s profile.

Advanced Eager Loading Techniques

Conditional eager loading lets us filter related models:

$posts = Post::with(['comments' => function ($query) {
    $query->where('status', 'approved')->orderBy('created_at', 'desc');
}])->get();

This only loads approved comments and orders them by creation date.

Loading specific columns reduces memory usage and improves performance:

$posts = Post::with(['user:id,name,email'])->get();

This only loads the ID, name, and email columns from the users table.

Counting related models without loading them:

$posts = Post::withCount(['comments', 'tags'])->get();

foreach ($posts as $post) {
    echo "Post: {$post->title}";
    echo "Comments: {$post->comments_count}";
    echo "Tags: {$post->tags_count}";
}

The withCount() method adds count columns to our results without loading the actual related models.

Conditional counts provide even more flexibility:

$posts = Post::withCount([
    'comments',
    'comments as approved_comments_count' => function ($query) {
        $query->where('status', 'approved');
    }
])->get();

This gives us both total comment count and approved comment count.

Loading relationships after model retrieval:

$posts = Post::all();
$posts->load(['user', 'tags']);

The load() method performs eager loading on an existing collection.

Loading relationships only if they haven’t been loaded yet:

$posts = Post::all();
$posts->loadMissing('user');

This prevents duplicate queries if the relationship was already loaded.

Working with Aggregates

Laravel provides methods to load relationship aggregates alongside our models:

$users = User::withCount('posts')
    ->withAvg('posts', 'views')
    ->withSum('posts', 'views')
    ->withMax('posts', 'created_at')
    ->withMin('posts', 'created_at')
    ->get();

foreach ($users as $user) {
    echo "User: {$user->name}";
    echo "Posts: {$user->posts_count}";
    echo "Average views: {$user->posts_avg_views}";
    echo "Total views: {$user->posts_sum_views}";
    echo "Latest post: {$user->posts_max_created_at}";
    echo "First post: {$user->posts_min_created_at}";
}

These aggregate methods perform calculations on related models without loading the actual data, making them very efficient for statistics and reporting.

Complex nested eager loading handles sophisticated data requirements:

$countries = Country::with([
    'users' => function ($query) {
        $query->where('status', 'active')->with('profile');
    },
    'users.posts' => function ($query) {
        $query->published()->with('tags');
    }
])->get();

This query loads countries with their active users (including profiles) and all published posts from those users (including tags).

Eager Loading Polymorphic Relationships

Polymorphic relationships can also be eager loaded:

$images = Image::with('imageable')->get();

For more control over polymorphic eager loading:

$images = Image::with([
    'imageable' => function ($morphTo) {
        $morphTo->morphWith([
            User::class => ['profile'],
            Post::class => ['category', 'tags'],
        ]);
    }
])->get();

This loads different relationships depending on the type of the parent model.

Checking for relationship existence without loading data:

$posts = Post::withExists('comments')->get();

foreach ($posts as $post) {
    if ($post->comments_exists) {
        echo "Post has comments";
    }
}

The withExists() method adds a boolean column indicating whether the relationship has any records.

Conclusion

In this article, we’ve built a complete blog system demonstrating every relationship type – from basic one-to-one connections between users and profiles to sophisticated polymorphic relationships and advanced “through” connections.

For more foundational Laravel concepts to build upon these advanced relationship techniques, explore our comprehensive Laravel Eloquent ORM beginner guide.

Leave a Comment