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:
php artisan make:migration create_tasks_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('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');
}
};
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.
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):
php artisan make:model Task
<?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:
<?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:
<?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);
}
}
<?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);
}
}
<?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:
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']);
});
<?php
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class); // "tag_task" inferred by convention
}
<?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
Migrate the task tracker from arrays to Eloquent
Replace Week 10's static array with real models, migrations and relationships.
Requirements:
- Write migrations for
users(Laravel's default one already exists),tasks(belongs to a user),tags, and atag_taskjoin table. - Build
User,TaskandTagmodels with correct$fillablearrays and relationship methods (User::tasks(),Task::user(),Task::tags(),Tag::tasks()). - Update
TaskControllerto useTask::create(),Task::findOrFail(),->update()and->delete()instead of the static array from Week 10. - Update the create-task form to accept multiple tags (a multi-select or checkboxes), and use
sync()when storing them. - Update the tasks index view to display each task's tags via the relationship.
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?
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()?
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?
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?
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.