Week 11: Eloquent ORM I — Models, Migrations & Relationships

Every controller so far has used a hardcoded array as a stand-in for real data. This week replaces it for good: Eloquent, Laravel's ORM, turns the raw PDO repository classes from Module 1 into models with migrations for schema and typed relationship methods for the joins you wrote by hand in Week 6.

Module 11 of 28 Week 11 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Define Eloquent models & write versioned schema changes as migrations
  • Perform CRUD through a model instead of raw SQL
  • Define hasMany, belongsTo & many-to-many relationships

1. Migrations

Week 6 wrote schema as raw CREATE TABLE SQL. A migration is that same schema expressed as version-controlled PHP — every schema change is a file, applied in order, and reversible:

terminal
php artisan make:migration create_tasks_table
database/migrations/..._create_tasks_table.php
<?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('tasks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title', 200);
            $table->boolean('is_done')->default(false);
            $table->date('due_date')->nullable();
            $table->timestamps(); // created_at + updated_at, both automatic
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};
terminal
php artisan migrate          # runs every pending migration's up()
php artisan migrate:rollback # undoes the last batch, running down()

foreignId('user_id')->constrained()->cascadeOnDelete() is FOREIGN KEY (user_id) REFERENCES users(id) from Week 6, plus ON DELETE CASCADE — deleting a user deletes their tasks automatically. $table->timestamps() adds both created_at and updated_at columns, which Eloquent then maintains for you on every save without any extra code.

Migrations are how a team shares schema changes

Instead of one developer running ad-hoc SQL against a shared database (and everyone else's local copy silently drifting out of sync), migration files get committed to Git like any other code. php artisan migrate brings any machine — a teammate's laptop, CI, production — to the exact same schema state, in order, every time.

2. Your First Model

An Eloquent model is a class representing one table — by convention, the Task model maps to the tasks table automatically (the plural, snake_case form of the class name):

terminal
php artisan make:model Task
app/Models/Task.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Task extends Model
{
    protected $fillable = ['title', 'is_done', 'due_date', 'user_id'];
}

$fillable is a security detail worth understanding, not skipping: it whitelists which columns can be set via mass-assignment (Task::create([...]), covered next). Without it, a form accidentally (or maliciously) submitting an is_admin field could overwrite a column it was never meant to touch — $fillable is what stops that.

3. CRUD via Eloquent

Compare this to the hand-written ProductRepository from Week 7 — same operations, now expressed through the model directly:

eloquent-crud.php
<?php

use App\Models\Task;

// Create
$task = Task::create([
    'title' => 'Write Week 11 notes',
    'user_id' => 1,
]);

// Read
$task = Task::find(5);        // null if not found
$task = Task::findOrFail(5);  // throws ModelNotFoundException (Laravel turns this into a 404)
$all = Task::all();
$pending = Task::where('is_done', false)->get();

// Update
$task->is_done = true;
$task->save();
// or, in one call:
$task->update(['is_done' => true]);

// Delete
$task->delete();

Every one of these compiles down to a prepared statement, exactly like Week 7's hand-written PDO calls — Eloquent is a layer of convenience over the same underlying safety guarantees, not a different mechanism.

4. Relationships

A relationship method describes how one model connects to another — Eloquent turns the foreign keys from your migrations into method calls that return related models directly:

app/Models/User.php
<?php

namespace App\Models;

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

class User extends Model
{
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }
}
app/Models/Task.php — the inverse side
<?php

namespace App\Models;

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

class Task extends Model
{
    protected $fillable = ['title', 'is_done', 'due_date', 'user_id'];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
using the relationship
<?php

$user = User::find(1);
foreach ($user->tasks as $task) { // no parentheses -- accessed as a property
    echo $task->title;
}

$task = Task::find(5);
echo $task->user->name; // walks the relationship the other direction

$user->tasks (no parentheses) triggers the query lazily the first time it's accessed and caches the result on the model instance — $user->tasks() (with parentheses) returns the underlying query builder instead, letting you chain further conditions before running it, as covered next week.

5. Many-to-Many Relationships

Week 6's post_tags join table pattern maps onto Eloquent's belongsToMany:

database/migrations/..._create_tags_table.php
Schema::create('tags', function (Blueprint $table) {
    $table->id();
    $table->string('name', 50)->unique();
});

Schema::create('tag_task', function (Blueprint $table) { // join table, alphabetical by convention
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->foreignId('task_id')->constrained()->cascadeOnDelete();
    $table->primary(['tag_id', 'task_id']);
});
app/Models/Task.php — with tags
<?php

use Illuminate\Database\Eloquent\Relations\BelongsToMany;

public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class); // "tag_task" inferred by convention
}
using it
<?php

$task->tags()->attach($tagId);      // insert into the join table
$task->tags()->detach($tagId);      // remove that one association
$task->tags()->sync([1, 3, 5]);     // replace all associations with exactly these IDs

foreach ($task->tags as $tag) {
    echo $tag->name;
}

Eloquent's default naming convention (alphabetized singular model names joined with an underscore — tag_task, not task_tag) is worth knowing precisely, since deviating from it just means passing the table name explicitly as belongsToMany(Tag::class, 'custom_table_name').

6. Hands-on Exercise

Hands-on

Migrate the task tracker from arrays to Eloquent

Replace Week 10's static array with real models, migrations and relationships.

Requirements:

  1. Write migrations for users (Laravel's default one already exists), tasks (belongs to a user), tags, and a tag_task join table.
  2. Build User, Task and Tag models with correct $fillable arrays and relationship methods (User::tasks(), Task::user(), Task::tags(), Tag::tasks()).
  3. Update TaskController to use Task::create(), Task::findOrFail(), ->update() and ->delete() instead of the static array from Week 10.
  4. Update the create-task form to accept multiple tags (a multi-select or checkboxes), and use sync() when storing them.
  5. Update the tasks index view to display each task's tags via the relationship.
Hint

If Task::create([...]) silently drops a field you expected to save, check $fillable first — a column missing from that array is exactly what mass-assignment protection is designed to block.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What problem does the $fillable array on a model actually prevent?

Mass-assignment vulnerabilities. Without it, calling Task::create($request->all()) would let a form (or a maliciously crafted request) set any column on the model — including ones like is_admin or user_id that were never meant to be user-controllable. $fillable whitelists exactly which columns mass-assignment is allowed to touch.

Q2

What's the difference between $user->tasks and $user->tasks()?

$user->tasks (property access, no parentheses) triggers the relationship's query immediately and returns the actual collection of results, caching it on the model afterward. $user->tasks() (calling it as a method) returns the underlying query builder instead — unevaluated, so you can chain additional conditions (->where(...), ->orderBy(...)) before the query actually runs.

Q3

Why does deleting a user cascade to delete their tasks in this week's migration?

Because the migration declared ->cascadeOnDelete() on the user_id foreign key — this is a database-level constraint (ON DELETE CASCADE), not application logic. Without it, deleting a user row while tasks still reference it via user_id would either fail outright (a foreign key violation) or leave orphaned task rows pointing at a user that no longer exists, depending on the constraint's configuration.

Q4

Why does a many-to-many relationship (Task::tags()) need a join table, but Task::user() doesn't?

Same reasoning as Week 6's normalization discussion: a belongsTo relationship (a task has exactly one user) fits in a single foreign-key column on the tasks table. A many-to-many relationship — a task can have multiple tags, and a tag can apply to multiple tasks — can't be represented by a single foreign key on either side, so belongsToMany requires (and automatically queries through) a separate join table holding one row per pairing.