Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Migrations & Eloquent

Schema migrations, models and CRUD with Eloquent.

Every real app needs a database. Laravel splits the job in two. Migrations describe the schema as version-controlled PHP, and Eloquent maps each table to a model class so you can work with rows as objects.

This lesson walks through the real database layer of the Projects & Tasks example that runs inside this app. That means its four migrations, its models, its seeder and the API controller that queries it. Every code block captioned with a file path is copied from the repository. Blocks marked Illustrative show techniques the example doesn't need.

Migrations: schema as code

A migration is a class with an up() method (apply the change) and a down() method (reverse it). Migrations live in database/migrations, run in filename (timestamp) order, and are recorded in a migrations table, so each one runs exactly once per database. They're generated with Artisan:

php artisan make:migration create_projects_table
php artisan make:model Task -mf          # model + migration + factory in one go

The example has four migrations. Here's the first:

database/migrations/2026_10_18_151927_create_projects_table.php

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        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();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('projects');
    }
};

foreignId('user_id')->constrained() creates an unsigned BIGINT column and a real foreign key to users.id. cascadeOnDelete() means deleting a user deletes their projects.

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']);
});

There are a few deliberate design choices here:

  • user_id is nullable with nullOnDelete(). A task can be unassigned, and deleting the assignee un-assigns the task instead of deleting it.
  • A nullable completed_at replaces a done boolean. It answers "is it done?" (IS NOT NULL) and "when?".
  • priority is a plain string, not a database ENUM. The allowed values live in a PHP enum, so adding one never needs a schema change.
  • The composite index [project_id, completed_at] matches the most common query: open tasks for one project.

app/Enums/TaskPriority.php

enum TaskPriority: string
{
    case Low = 'low';
    case Medium = 'medium';
    case High = 'high';

    public function label(): string
    {
        return ucfirst($this->value);
    }
}

Tags are many-to-many, so they need a pivot table. Laravel's convention is the two singular table names in alphabetical order: tag_task.

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']);
});
Migration (PHP) Schema::create('tasks', $t->id(); $t->foreignId('project_id') $t->string('title'); $t->string('priority'); $t->timestamp('completed_at') $t->timestamps(); php artisan migrate tasks (MySQL table) id BIGINT PK project_id BIGINT FK title VARCHAR(255) priority VARCHAR(255) completed_at TIMESTAMP NULL created_at TIMESTAMP updated_at TIMESTAMP Task model $task->id int $task->project Project $task->title string ->priority TaskPriority ->completed_at Carbon|null $task->created_at Carbon $task->save() creates reads / writes rows

Running and rolling back

php artisan migrate                 # run pending migrations
php artisan migrate:status          # which have run?
php artisan migrate:rollback        # undo the last batch
php artisan migrate:fresh --seed    # drop everything, re-run, seed (local only!)
php artisan make:migration add_position_to_tasks_table   # change an existing table

Illustrative, not in this app. Adding a column later means writing a new migration:

Schema::table('tasks', function (Blueprint $table) {
    $table->unsignedInteger('position')->default(0)->after('title');
});

Once a migration has run on a shared or production database, treat it as immutable. Fix mistakes with a new migration. Editing the old file only changes fresh installs, and environments drift apart. This app's own CMS schema shows the pattern: add_blog_type_id_to_posts_table evolves posts rather than rewriting create_posts_table.

Defining models

By convention the Task model maps to the tasks table, with primary key id and automatic created_at/updated_at timestamps. You only declare what differs from the conventions, which attributes may be mass-assigned, how to cast columns, and the relationships.

app/Models/Task.php

class Task extends Model
{
    /** @use HasFactory<TaskFactory> */
    use HasFactory;

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

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

    /**
     * @return BelongsTo<Project, $this>
     */
    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }

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

    /**
     * @return BelongsToMany<Tag, $this>
     */
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class);
    }

    // ... scopes and markComplete(), shown below
}

The casts do the type conversion for you. $task->priority is a TaskPriority enum, not the string 'high'. $task->due_on is a Carbon date, and $task->completed_at is a Carbon datetime or null. The relationship is named assignee() because that's what it means, even though the column is user_id, so the foreign key is passed explicitly.

app/Models/Project.php

class Project extends Model
{
    /** @use HasFactory<ProjectFactory> */
    use HasFactory;

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

    public function getRouteKeyName(): string
    {
        return 'slug';
    }

    /**
     * @return BelongsTo<User, $this>
     */
    public function owner(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    /**
     * @return HasMany<Task, $this>
     */
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }
}

Notice that user_id is not in Project::$fillable. A project's owner is never taken from user input. Instead, projects are created through the owner ($user->projects()->create([...])), which sets user_id safely. The CMS Page model is older code. It declares its casts with the $casts property, the pre-Laravel 11 style. New models, like Task above, use the casts() method, which is what make:model generates today and which can call other code when building the list. Both work the same way. Here the array cast turns the JSON blocks column into a PHP array:

app/Models/Page.php

protected $fillable = ['title', 'slug', 'blocks'];

protected $casts = [
    'blocks' => 'array',
];

Two syntaxes for the same thing

Laravel 13 also supports configuring models with PHP attributes. This app mostly uses the classic properties (with the casts() method for casts), but you'll see all of these in the wild:

Concern Laravel 13 attribute / method Classic property (this app)
Mass-assignable columns #[Fillable([...])] protected $fillable = [...]
Allow everything #[Unguarded] protected $guarded = []
Custom table name #[Table('todo_items')] protected $table = 'todo_items'
Casts protected function casts(): array (this app's Task) protected $casts = [...] (older style, Page)

Mass assignment. Task::create($request->all()) would let an attacker post any column they like. $fillable is an allow-list, and anything else is silently dropped. The example goes further: the API only ever passes $request->validated() (rules in StoreTaskRequest), and a project's owner can't be mass-assigned at all. Consider Model::preventSilentlyDiscardingAttributes() in local development so typos throw instead of disappearing.

Accessors & mutators

An accessor computes a value when you read an attribute. A mutator transforms a value when you set one. Both are a method returning Attribute::make(), named after the attribute in camelCase. These two are real, on this app's Task model:

app/Models/Task.php

use Illuminate\Database\Eloquent\Casts\Attribute;

/**
 * True when the task has a due date in the past and is still open.
 */
protected function isOverdue(): Attribute
{
    return Attribute::make(
        get: fn (): bool => $this->completed_at === null
            && $this->due_on !== null
            && $this->due_on->isBefore(today()),
    );
}

/**
 * Trim stray whitespace from titles before they are saved.
 */
protected function title(): Attribute
{
    return Attribute::make(
        set: fn (string $value): string => trim($value),
    );
}
  • $task->is_overdue reads the accessor. The method is isOverdue, the attribute is snake_case.
  • Task::create(['title' => ' Fix bug ']) stores "Fix bug", because the mutator runs on every assignment, including mass assignment.
  • An accessor isn't a column: it isn't in toArray() unless you list it in $appends or, as here, put it in the API Resource. TaskResource returns it as is_overdue.

Keep accessors cheap. One that touches a relationship ($this->project->name) runs a query per model the first time, which brings back the N+1 problem covered in Eloquent Relationships.

Creating records

The demo seeder is real-world creation code. It runs on every production deploy and every local container start, so it's idempotent (safe to run twice) and wraps everything in a transaction:

database/seeders/TutorialDemoSeeder.php

public function run(): void
{
    if (Project::where('slug', 'website-relaunch')->exists()) {
        return;
    }

    DB::transaction(function () {
        $users = collect(['Ava Patel', 'Ben Okafor', 'Chloe Martin', 'Dan Reyes'])
            ->mapWithKeys(fn (string $name) => [$name => User::firstOrCreate(
                ['email' => Str::slug($name, '.').'@demo.example'],
                ['name' => $name, 'password' => Str::random(64)],
            )]);

        $tags = collect(['Frontend', 'Backend', 'Bug', 'Design', 'DevOps'])
            ->mapWithKeys(fn (string $name) => [$name => Tag::firstOrCreate(
                ['slug' => Str::slug($name)],
                ['name' => $name],
            )]);

        foreach ($this->projects() as $projectData) {
            $project = $users[$projectData['owner']]->projects()->create([
                'name' => $projectData['name'],
                'slug' => $projectData['slug'],
                'description' => $projectData['description'],
            ]);

            foreach ($projectData['tasks'] as [$title, $priority, $assignee, $taskTags, $done, $dueInDays]) {
                $task = $project->tasks()->create([
                    'title' => $title,
                    'priority' => $priority,
                    'user_id' => $assignee ? $users[$assignee]->id : null,
                    'due_on' => $dueInDays === null ? null : now()->addDays($dueInDays),
                    'completed_at' => $done ? now()->subDays(2) : null,
                ]);

                $task->tags()->attach($tags->only($taskTags)->pluck('id'));
            }
        }
    });
}

That one method shows most of the ways to create records:

  • firstOrCreate($match, $extra) finds a row matching the first array, or inserts it with both arrays merged. That's what makes re-running the seeder safe.
  • $user->projects()->create([...]) creates through a relationship, so Eloquent fills in user_id.
  • $project->tasks()->create([...]) does the same for project_id.
  • $task->tags()->attach($ids) inserts rows into the tag_task pivot table.

The result is 3 projects, 20 tasks, 5 tags and 4 demo users (Ava Patel, Ben Okafor, Chloe Martin and Dan Reyes). The seeded users get a random 64-character password, so they exist only as assignees and can never log in. A test pins all of that down:

tests/Feature/Demo/TutorialDemoSeederTest.php

public function test_seeder_is_idempotent(): void
{
    $this->seed(TutorialDemoSeeder::class);
    $this->seed(TutorialDemoSeeder::class);

    $this->assertSame(3, Project::count());
    $this->assertSame(20, Task::count());
    $this->assertSame(5, Tag::count());
    $this->assertSame(4, User::count());
    $this->assertTrue(Project::where('slug', 'website-relaunch')->exists());
}

Illustrative, not in this app. Other creation styles you'll use:

$task = new Task;                 // new up, set, save
$task->project_id = $project->id;
$task->title = 'Write migration';
$task->save();

Task::create([...]);              // mass assignment (needs $fillable)

Task::updateOrCreate(
    ['project_id' => $project->id, 'title' => 'Deploy'],   // match on
    ['priority' => TaskPriority::High],                    // set
);

Reading records

Task::all();                                   // every row (careful on big tables!)
Task::find($id);                               // by primary key, or null
Task::findOrFail($id);                         // by primary key, or ModelNotFoundException → 404
Project::firstWhere('slug', 'website-relaunch');
Task::whereNull('completed_at')->count();      // 14 open tasks in the seeded data
Project::firstWhere('slug', 'mobile-app-api')->tasks()->pluck('title');
Task::latest()->paginate(10);                  // LengthAwarePaginator, ?page=2

Every query method returns a builder until you call a terminal method (get, first, find, count, pluck, paginate, exists…). Only then does SQL run.

Query scopes

The example names its reusable constraints with local scopes, using Laravel's #[Scope] attribute:

app/Models/Task.php

#[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);
}

Callers then read like English: Task::open()->priority(TaskPriority::High)->get(). The CMS Post model in this app uses the older naming style, scopePublished(), which still works and is called as Post::published().

A real filtered query

The GET /api/demo/tasks endpoint combines the scopes with when(), which only adds a clause when a filter is present:

app/Http/Controllers/Api/TaskController.php

public function index(Request $request): AnonymousResourceCollection
{
    $filters = $request->validate([
        'priority' => ['nullable', Rule::enum(TaskPriority::class)],
        'status' => ['nullable', Rule::in(['open', 'completed'])],
        'project' => ['nullable', 'string', 'exists:projects,slug'],
        'search' => ['nullable', 'string', 'max:100'],
    ]);

    $tasks = Task::query()
        ->with(['project', 'assignee', 'tags'])
        ->when($filters['priority'] ?? null, fn ($query, string $priority) => $query->priority(TaskPriority::from($priority)))
        ->when(($filters['status'] ?? null) === 'open', fn ($query) => $query->open())
        ->when(($filters['status'] ?? null) === 'completed', fn ($query) => $query->completed())
        ->when($filters['project'] ?? null, fn ($query, string $slug) => $query->whereRelation('project', 'slug', $slug))
        ->when($filters['search'] ?? null, fn ($query, string $search) => $query->whereLike('title', "%{$search}%"))
        ->orderBy('id')
        ->paginate(10)
        ->withQueryString();

    return TaskResource::collection($tasks);
}

Notice that the filters are validated first. ?priority=urgent gets a 422 response instead of a database error. whereRelation('project', 'slug', $slug) filters by a column on a related table, and with([...]) eager-loads the relationships that the JSON needs. That's covered in depth in the relationships lesson.

See it running

This demo calls that endpoint live. Change the filters and watch the real data, plus the X-Query-Count header the app adds to every demo response:

Open it directly: /api/demo/tasks?status=open&priority=high, or send the raw request here. Edit the query string and press Send:

An invalid filter never reaches the database. Validation answers first with a 422 and the error for that field:

See the SQL

Build a query and watch the SQL and bindings that Eloquent would send. Notice the ? placeholders: values are always bound separately, which is what protects you from SQL injection.

In real code, ->toSql(), ->toRawSql() and ->dd() show the same thing:

Task::open()->orderBy('due_on')->toRawSql();
// select * from `tasks` where `completed_at` is null order by `due_on` asc

Updating and deleting

The example's one write operation lives on the model, so every caller completes a task the same way:

app/Models/Task.php

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);
}

update() mass-assigns and saves in one step, and Eloquent only writes the dirty columns. The guard clause makes the method idempotent: completing a task twice doesn't fire the TaskCompleted event twice. A test in tests/Feature/Demo/TaskCompletionTest.php checks exactly that, and the queues lesson follows the event to its queued listener.

Illustrative, not in this app. Other write operations you'll use:

$task->isDirty('priority');                   // changed but not saved?
$task->wasChanged('priority');                // changed in the last save?

// Bulk update: one query, but no model events fire
$project->tasks()->open()->update(['priority' => TaskPriority::High]);

$task->delete();
Task::destroy([1, 2, 3]);
Task::completed()->where('completed_at', '<', now()->subYear())->delete();

Soft deletes

Illustrative, not in this app. Add $table->softDeletes() to the migration and the SoftDeletes trait to the model. delete() then sets deleted_at instead of removing the row, and normal queries hide those rows automatically:

use Illuminate\Database\Eloquent\SoftDeletes;

class Project extends Model
{
    use SoftDeletes;
}

$project->delete();                 // sets deleted_at
Project::withTrashed()->find(5);    // include soft-deleted rows
$project->restore();
$project->forceDelete();            // really delete

Collections

get() and relationship properties return an Eloquent Collection, a supercharged array with dozens of chainable methods. Using the seeded Website Relaunch project:

$tasks = Project::firstWhere('slug', 'website-relaunch')->tasks;   // 8 tasks

$tasks->whereNull('completed_at')->count();                         // 6
$tasks->groupBy(fn (Task $t) => $t->priority->value)->map->count(); // ['high' => 3, 'medium' => 3, 'low' => 2]
$tasks->sortBy('due_on')->take(3)->pluck('title');

Filtering a collection happens in PHP, after the rows are loaded. Filtering a query (->where() before ->get()) happens in the database. For large tables, always narrow the query first.

Factories: data for tests

The seeder uses fixed data, because Faker is a dev-only dependency and production needs identical demo content every time. Tests use factories instead, which generate fresh, random models:

database/factories/TaskFactory.php

public function definition(): array
{
    return [
        'project_id' => Project::factory(),
        'user_id' => User::factory(),
        'title' => fake()->sentence(4),
        'description' => fake()->optional()->paragraph(),
        'priority' => fake()->randomElement(TaskPriority::cases()),
        'due_on' => fake()->optional()->dateTimeBetween('now', '+1 month'),
        'completed_at' => null,
    ];
}

public function completed(): static
{
    return $this->state(fn (array $attributes) => [
        'completed_at' => now(),
    ]);
}

public function highPriority(): static
{
    return $this->state(fn (array $attributes) => [
        'priority' => TaskPriority::High,
    ]);
}

'project_id' => Project::factory() means "create a project (and its owner) unless one is supplied". One line in a test builds the whole graph:

tests/Feature/Demo/DemoApiTest.php

public function test_project_is_resolved_by_slug_with_tasks_assignees_and_tags(): void
{
    $task = Task::factory()->highPriority()->create();
    $task->tags()->attach(Tag::factory()->create(['name' => 'Backend', 'slug' => 'backend']));

    $this->getJson(route('api.demo.projects.show', $task->project))
        ->assertOk()
        ->assertJsonPath('data.tasks.0.title', $task->title)
        ->assertJsonPath('data.tasks.0.priority', 'high')
        ->assertJsonPath('data.tasks.0.assignee.name', $task->assignee->name)
        ->assertJsonPath('data.tasks.0.tags.0.slug', 'backend');
}

Key takeaways

  • Migrations version your schema. Foreign keys, nullability and indexes are design decisions, and a migration that has run in production is never edited.
  • Models map to tables by convention. Declare $fillable, casts (enums and dates) and relationships, and keep ownership out of mass assignment.
  • Queries are lazy builders until a terminal method runs. Scopes plus when() give you clean, validated filtering.
  • Accessors and mutators (Attribute::make(get:, set:)) compute values on read and clean them on write, like is_overdue and the trimmed title on Task.
  • Put write rules on the model (markComplete()) so they're consistent and idempotent.
  • Seeders give you fixed, idempotent demo data. Factories give tests random data with states like completed().