Lập trình · 22/09/2026

Laravel 13 Course – Lesson 08: Laravel 13 Migrations, Schema and Safe Rollbacks

Lesson 8 — Migrations, schema and rollback: begin TaskFlow's data layer after lesson 7's preview form. We create schema without turning preview into persistence. Constraints are checked against a real in-memory database, not merely reviewed as plausible code.

Migration, Schema và rollback an toàn trong Laravel 13

Lesson 8 — Migrations, schema and rollback: begin TaskFlow's data layer after lesson 7's preview form. We create schema without turning preview into persistence. Constraints are checked against a real in-memory database, not merely reviewed as plausible code.

1. Decide relationships and deletion behavior

users 1 ── n projects (owner_id)
projects 1 ── n tasks (project_id)
users 1 ── n tasks (nullable assignee_id)

A project has one owner. A task belongs to a project and may be unassigned. Deleting a project owner is restricted, as is deleting a project that still has tasks. Deleting a user who is only assigned a task clears assignee_id. These are explicit preservation choices for this example, not universal product defaults.

A foreign key proves the referenced record exists; it does not prove an assignee is allowed to participate in the project. Membership and authorization remain application concerns for later lessons.

2. Create projects before tasks

Create database/migrations/2026_09_22_000100_create_projects_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('projects', function (Blueprint $table) {
            $table->id();
            $table->foreignId('owner_id')->constrained('users')->restrictOnDelete();
            $table->string('name', 120);
            $table->string('slug', 140);
            $table->timestamps();
            $table->unique(['owner_id', 'slug']);
        });
    }
    public function down(): void
    {
        Schema::dropIfExists('projects');
    }
};

The owner_id/slug unique constraint allows different owners to reuse a slug but prevents duplicates for one owner. A future URL containing only that slug would not uniquely identify a project: use an ID or include owner scope. Do not impose global uniqueness unless the product actually requires it.

3. Design tasks and query-oriented indexes

Create database/migrations/2026_09_22_000200_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('project_id')->constrained()->restrictOnDelete();
            $table->foreignId('assignee_id')->nullable()->constrained('users')->nullOnDelete();
            $table->string('title', 120);
            $table->text('description')->nullable();
            $table->string('status', 20)->default('todo');
            $table->string('priority', 10)->default('normal');
            $table->timestamp('due_at')->nullable();
            $table->timestamps();
            $table->index(['project_id', 'status', 'id']);
            $table->index(['assignee_id', 'due_at']);
        });
    }
    public function down(): void
    {
        Schema::dropIfExists('tasks');
    }
};

The project_id/status/id index supports the intended direction of project/status filtering with a stable ID ordering. The assignee_id/due_at index targets assigned-work lists by deadline. These are design hypotheses, not measured performance claims; later optimization requires execution plans and realistic data.

Status and priority are strings with defaults, not CHECK-constrained value sets. Application validation must still restrict them; direct SQL could write other values. SQLite also does not enforce VARCHAR lengths identically to every other engine, so retain the 120-character title validation.

4. Run migrations deliberately

Confirm the project, connection and target database before writing. For a dedicated recoverable local TaskFlow database:

php artisan migrate:status
php artisan migrate --pretend
php artisan migrate
php artisan migrate:status

--pretend previews SQL but is not a sandbox for arbitrary PHP side effects in custom migrations. Our examples contain only schema operations. Once a migration has been shared or deployed, add another migration for changes instead of editing history and expecting migrate to rerun it.

The migration documentation explains batch rollback and --step. Dropping a table in down() does not recover its previous data, and migrating again creates an empty schema.

5. Test rollback in a disposable database

This standalone test explicitly selects SQLite :memory: and clears DB_URL before connecting. Create tests/Feature/SchemaRollbackTest.php:

<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;

class SchemaRollbackTest extends TestCase
{
    public function test_task_migration_round_trip_in_memory(): void
    {
        config([
            'database.default' => 'sqlite',
            'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null,
            'database.connections.sqlite.foreign_key_constraints' => true,
        ]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
        $this->assertTrue(Schema::hasTable('tasks'));
        $this->artisan('migrate:rollback', ['--step' => 1, '--force' => true])->assertSuccessful();
        $this->assertFalse(Schema::hasTable('tasks'));
        $this->assertTrue(Schema::hasTable('projects'));
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
        $this->assertTrue(Schema::hasTable('tasks'));
    }
}

At lesson 8, tasks is the latest migration, so rolling back one step removes tasks while retaining projects. After later migrations are added, do not assume the last migration is still tasks: scope the round-trip test to this milestone or revise it for the new history.

The executable example includes an equivalent rollback test and checks for orphan tasks, duplicate owner-scoped slugs and restricted deletion of projects containing tasks. The entire suite passed 18 tests with 52 assertions. Rollback ran only in memory, not against the on-disk local database. This does not establish MySQL/PostgreSQL behavior or production DDL locking.

6. Technical rollback is not a recovery plan

Production changes need backups with tested restoration, maintenance planning when appropriate, checks for constraint violations and compatibility between old/new application versions. For large changes, consider adding new columns, backfilling in batches, switching code and removing old columns in a later deployment. Never use migrate:fresh, refresh or reset on data you must retain.

Adding a required column to populated tables requires a plan for existing rows. Adding uniqueness requires handling duplicates first. Large indexes need engine-specific lock and resource assessment. A green SQLite test cannot answer those operational questions.

7. Exercise

Explain why tasks must be dropped before projects. Test two owners sharing a slug and deleting an assignee without deleting their task. Identify exactly which data disappears when tasks is rolled back. Continue only when you can distinguish recreating a table from recovering its contents.

Navigation: Lesson 7 · Roadmap. Next we create related practice data with factories and seeders.

Laravel 13 course navigation

Previous lesson (07) · Next lesson (09) · All 37 lessons

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

No comments yet. Be the first to share your thoughts.