Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Eloquent Relationships

One-to-many, many-to-many, eager loading and the N+1 problem.

Real applications are mostly about how records relate to each other. A project has many tasks, a task belongs to a user and tasks share tags. Eloquent lets you describe those relationships once, as methods on your models, and then query them with readable PHP instead of hand-written joins.

This lesson builds the data model for our running Projects & Tasks app. It covers the core relationship types and then the most common performance bug in Laravel code: the N+1 query problem.

The data model

Here is the schema we're working with. Every arrow is a foreign key, and every foreign key becomes a relationship method.

users id name, email password projects id user_id (owner) name, slug (unique) tasks id project_id user_id (assignee) title, priority, completed_at tag_task (pivot) tag_id, task_id tags id, name, slug 1 → many 1 → many assignee many ↔ many

These tables exist in this app. Here is the up() body of each migration:

database/migrations/2026_10_18_151927_create_projects_table.php

Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->string('slug')->unique();
    $table->text('description')->nullable();
    $table->timestamps();
});

database/migrations/2026_10_18_151928_create_tasks_table.php

Schema::create('tasks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('project_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
    $table->string('title');
    $table->text('description')->nullable();
    $table->string('priority')->default(TaskPriority::Medium->value);
    $table->date('due_on')->nullable();
    $table->timestamp('completed_at')->nullable();
    $table->timestamps();

    $table->index(['project_id', 'completed_at']);
});

database/migrations/2026_10_18_151929_create_tags_table.php

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

database/migrations/2026_10_18_151931_create_tag_task_table.php

Schema::create('tag_task', function (Blueprint $table) {
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->foreignId('task_id')->constrained()->cascadeOnDelete();

    $table->primary(['tag_id', 'task_id']);
});

foreignId()->constrained() follows Laravel's naming conventions: project_id references projects.id. Because the schema follows those conventions, the relationship methods below need no extra arguments.

Defining relationships

One-to-many: hasMany and belongsTo

The model holding the foreign key belongs to the other. The other side has many.

These are the real models from this app (imports and PHPDoc generics trimmed for space):

app/Models/Project.php

namespace App\Models;

class Project extends Model
{
    use HasFactory;

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

    // URLs use /projects/{slug} instead of /projects/{id}
    public function getRouteKeyName(): string
    {
        return 'slug';
    }

    public function owner(): BelongsTo
    {
        // Method name differs from the column, so name the key explicitly
        return $this->belongsTo(User::class, 'user_id');
    }

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

app/Models/Task.php

namespace App\Models;

use App\Enums\TaskPriority;
use App\Events\TaskCompleted;
use Illuminate\Database\Eloquent\Attributes\Scope;

class Task extends Model
{
    use HasFactory;

    protected $fillable = ['project_id', 'user_id', 'title', 'description', 'priority', 'due_on', 'completed_at'];

    protected function casts(): array
    {
        return [
            'priority'     => TaskPriority::class,   // string-backed enum
            'due_on'       => 'date',
            'completed_at' => 'datetime',
        ];
    }

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

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

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

    #[Scope]
    protected function open(Builder $query): void
    {
        $query->whereNull('completed_at');
    }

    #[Scope]
    protected function completed(Builder $query): void
    {
        $query->whereNotNull('completed_at');
    }

    #[Scope]
    protected function priority(Builder $query, TaskPriority $priority): void
    {
        $query->where('priority', $priority);
    }

    public function isComplete(): bool
    {
        return $this->completed_at !== null;
    }

    public function markComplete(): void
    {
        if ($this->isComplete()) {
            return;
        }

        $this->update(['completed_at' => now()]);

        TaskCompleted::dispatch($this);
    }
}

The #[Scope] attribute turns a protected method into a local query scope, so Task::open()->priority(TaskPriority::High)->get() reads like the business language. (Older code uses the scopeOpen() naming convention, which still works.)

Methods vs dynamic properties

Each relationship can be used in two ways, and the difference matters:

$project->tasks;            // Property: runs the query once, caches a Collection
$project->tasks();          // Method: returns a query builder you can keep chaining

$project->tasks()->completed()->count();             // SELECT COUNT(*) ... in SQL
$project->tasks->whereNotNull('completed_at')->count(); // loads ALL tasks, filters in PHP

Property or method? $post->comments and $post->comments() are different things. The property returns the loaded result (a Collection or model) and memoises it on the model. The method returns a Relation object, which is a query builder scoped to that parent. Use the method whenever you want the database to do the filtering, counting or paginating.

Many-to-many with a pivot table

belongsToMany uses the tag_task pivot table. The name is the two model names in alphabetical order, singular and joined by an underscore. Laravel gives you helpers for managing the links:

$task->tags()->attach($tagId);              // insert one pivot row
$task->tags()->detach($tagId);              // delete one pivot row
$task->tags()->sync([1, 4, 7]);             // make the set exactly this
$task->tags()->syncWithoutDetaching([9]);   // add, never remove
$task->tags()->toggle([4]);                 // flip membership

sync() is what you usually want behind a multi-select form field. It returns which IDs were attached, detached and updated, so you can log changes.

Illustrative — not in this app: if the pivot carries its own data, such as who applied a tag, declare it:

return $this->belongsToMany(Tag::class)
    ->withPivot('added_by')
    ->withTimestamps();

// later
$task->tags->first()->pivot->added_by;

Going through another model

A user owns projects, and those projects have tasks. User in this app has projects() and tasks() (the tasks assigned to them). To reach "all tasks in projects I own" without writing the join yourself, you'd add a hasManyThrough.

app/Models/User.php (relationship methods)

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

/**
 * Tasks assigned to this user.
 */
public function tasks(): HasMany
{
    return $this->hasMany(Task::class);   // tasks.user_id by convention
}

Illustrative — not in this app: every task in projects the user owns, reached through the projects table:

public function projectTasks(): HasManyThrough
{
    return $this->hasManyThrough(Task::class, Project::class);
}

Relationship cheat sheet

Relationship Foreign key lives on Example Returns
hasOne the other table User → Profile Model or null
belongsTo this table Task → Project Model or null
hasMany the other table Project → Tasks Collection
belongsToMany a pivot table Task ↔ Tags Collection
hasManyThrough two hops away User → Project → Tasks Collection
morphMany / morphTo *_type + *_id columns Comment on Task or Project Collection / Model

The N+1 query problem

Here's an innocent-looking Blade view listing tasks with their project name:

// Controller
$tasks = Task::latest()->take(50)->get();
@foreach ($tasks as $task)
    <li>{{ $task->title }}: {{ $task->project->name }}</li>
@endforeach

This page runs 51 queries: one to get the tasks, then one SELECT * FROM projects WHERE id = ? for every task, because $task->project is lazy-loaded the first time it's accessed. Add the assignee and the tags and you're at 151. It's fast on your laptop with 5 rows and painful in production with 50.

The fix is eager loading: tell Eloquent up front which relationships you need, and it fetches each one in a single WHERE id IN (...) query.

$tasks = Task::with(['project', 'assignee', 'tags'])
    ->latest()
    ->take(50)
    ->get();
// 4 queries total, however many tasks there are

Play with it. The simulator below shows the SQL Eloquent would run for each strategy:

Run it for real

The simulator above is illustrative. This one isn't. GET /api/demo/query-count is a real endpoint in this app. It lists every project with its tasks and each task's assignee, either lazily or with with('tasks.assignee'), and returns the SQL Laravel actually sent (see it running):

app/Http/Controllers/Api/QueryCountController.php

/**
 * Runs the same "projects → tasks → assignee" listing lazily or eagerly
 * and reports the SQL that actually hit the database.
 */
class QueryCountController extends Controller
{
    public function __invoke(Request $request): JsonResponse
    {
        $eager = $request->boolean('eager');

        DB::flushQueryLog();
        DB::enableQueryLog();

        $projects = $eager
            ? Project::with('tasks.assignee')->orderBy('id')->get()
            : Project::orderBy('id')->get();

        $rows = $projects->map(fn (Project $project) => [
            'project' => $project->name,
            'tasks' => $project->tasks->map(fn ($task) => [
                'title' => $task->title,
                'assignee' => $task->assignee?->name,
            ]),
        ]);

        $queries = DB::getQueryLog();
        DB::disableQueryLog();

        return response()->json([
            'eager' => $eager,
            'query_count' => count($queries),
            'queries' => array_map(fn (array $query) => [
                'sql' => $query['query'],
                'bindings' => $query['bindings'],
                'time_ms' => $query['time'],
            ], $queries),
            'rows' => $rows,
        ]);
    }
}

With 3 projects of 4 tasks each, the feature test DemoApiTest::test_eager_loading_runs_fewer_queries_than_lazy_loading asserts exactly 16 queries lazily (1 for projects + 3 for tasks + 12 for assignees) versus 3 eagerly. With the seeded demo data (3 projects, 20 tasks, 4 users) it's 22 versus 3. Compare both live:

Eager loading variations

// Nested: tasks → project → owner
Task::with('project.owner')->get();

// Constrained: only load open tasks for each project
Project::with(['tasks' => fn ($q) => $q->open()])->get();

// Only the columns you need (always include the keys!)
Task::with('project:id,name')->get();

// Already have the models? Load after the fact
$tasks->load('tags');
$project->loadMissing('tasks');

// Counts and aggregates without loading the rows
Project::withCount('tasks')
    ->withCount(['tasks as open_tasks_count' => fn ($q) => $q->open()])
    ->get();
// $project->tasks_count, $project->open_tasks_count

Catching N+1 automatically

Don't rely on code review to spot lazy loading. Make Laravel throw an exception in development. (Illustrative — not enabled in this app, because the query-count demo above needs lazy loading to actually happen.)

// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

Now any $task->project that wasn't eager-loaded throws a LazyLoadingViolationException locally. In production it's a no-op, so users never see the error. Model::shouldBeStrict() goes further and also flags silently discarded fillable attributes and missing attributes.

Watch out for loops in accessors and API resources. An accessor like protected function projectName(): Attribute { return Attribute::get(fn () => $this->project->name); } will reintroduce N+1 anywhere the model is serialised. API Resources should use whenLoaded('project') so they only include relations the controller chose to load. That's covered in Building APIs.

Try it: eager loading in the real API

GET /api/demo/projects/{slug} loads a project with its tasks, each task's assignee and its tags, all eager-loaded. Watch the X-Query-Count in the response line: it stays small no matter how many tasks the project has.

GET /api/demo/query-count runs the same listing twice on the server, once lazily and once with with(), and reports how many queries each took:

Querying across relationships

Sometimes you want to filter parents by their children, not load the children:

// Projects that have at least one overdue task
Project::whereHas('tasks', function ($q) {
    $q->open()->whereDate('due_on', '<', now());
})->get();

// Projects with no tasks at all
Project::doesntHave('tasks')->get();

// Tasks tagged "backend"
Task::whereRelation('tags', 'slug', 'backend')->get();

// Load only the tasks that matched the filter
Project::withWhereHas('tasks', fn ($q) => $q->priority(TaskPriority::High))->get();

whereHas compiles to an EXISTS (SELECT ...) subquery. For this to be fast, the foreign key needs an index. foreignId()->constrained() creates one for you on MySQL.

Creating related records

Create children through the relationship so the foreign key is set for you:

$task = $project->tasks()->create([
    'title'    => 'Write the release notes',
    'priority' => TaskPriority::High,
]);

$task->assignee()->associate($user);   // sets user_id
$task->save();

$task->tags()->sync($request->validated('tag_ids', []));

TutorialDemoSeeder creates its tasks exactly this way, through $project->tasks()->create([...]) and $task->tags()->attach(...). For multi-step writes, wrap them in a transaction (the seeder wraps its whole run in one) so you never end up with a task that has no tags because the second query failed:

use Illuminate\Support\Facades\DB;

$task = DB::transaction(function () use ($project, $data) {
    $task = $project->tasks()->create($data);
    $task->tags()->sync($data['tag_ids'] ?? []);

    return $task;
});

Polymorphic relationships

Illustrative — not in this app. Comments can belong to a task or a project. Rather than adding two nullable foreign keys, a polymorphic relation stores the type and the id:

Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->morphs('commentable');        // commentable_type + commentable_id, indexed
    $table->foreignId('user_id')->constrained();
    $table->text('body');
    $table->timestamps();
});

class Comment extends Model
{
    public function commentable(): MorphTo { return $this->morphTo(); }
}

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

Register a morph map so the database stores task rather than App\Models\Task. Otherwise renaming a class breaks old rows:

Relation::enforceMorphMap([
    'task'    => Task::class,
    'project' => Project::class,
]);

Key takeaways

  • Relationships are methods returning HasMany, BelongsTo, BelongsToMany and so on. Follow naming conventions and you rarely need extra arguments.
  • $model->relation returns cached results, while $model->relation() returns a query builder. Count and filter with the method.
  • N+1 is the most common Laravel performance bug. Fix it with with(), load() and withCount(), and catch it with Model::preventLazyLoading().
  • Use whereHas, whereRelation and withWhereHas to filter by related data. Index your foreign keys.
  • Write related records through the relationship and wrap multi-step writes in DB::transaction().