Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 5 – Databases and Eloquent

Migrations and schema design

Build Pathshala's fee tables with Laravel migrations: whole paise, foreign keys, unique indexes, a change() that lost nullable, and production guards.

  • Beginner
  • 15 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Create migrations, run them in file-name order, roll back a batch and squash them with schema:dump
  • Choose column types, indexes, unique constraints and foreign key delete rules for a table
  • Modify an existing column with change() without losing the modifiers it had
  • Explain why migrate:fresh and migrate --force are dangerous in production, and guard against them

Before you start

What you’ll learn · Before you start

What you will learn

  • Create migrations, run them in file-name order, roll back a batch and squash them with schema:dump
  • Choose column types, indexes, unique constraints and foreign key delete rules for a table
  • Modify an existing column with change() without losing the modifiers it had
  • Explain why migrate:fresh and migrate --force are dangerous in production, and guard against them

Before you start

On this page

Pathshala charges a term fee of 4,500.30 rupees in three instalments of 1,500.10, and added up as floats those three amounts came to 4500.299999999999. A migration: A schema change written as a PHP class with an up method that applies it and a down method that undoes it, kept in database/migrations and run in file-name order. More in “Your first CRUD feature with Eloquent” is where such choices are fixed for every copy of a project: the type of each column, the rows the database refuses, and what becomes of a student’s instalments when the student is deleted.

You will build Pathshala’s students, fee_instalments and fee_receipts tables with amounts in whole paise, a foreign key with a delete rule and a unique index over two columns. Then you will change a column and watch a forgotten ->nullable() stop the migration on the first student without a phone, squash six migrations into one SQL file, and see what migrate:fresh and --force do to a database that holds real rows.

Migrate runs pending files in name order, and rollback undoes a batch

A new Laravel project already has three files in database/migrations, and their names start with 0001_01_01_000000, 0001_01_01_000001 and 0001_01_01_000002. php artisan make:migration create_students_table adds a fourth whose name starts with the moment it was made, such as 2026_02_02_090000. Those prefixes are the whole ordering scheme: php artisan migrate sorts the file names, skips every file already listed in the database’s migrations table, runs the up() method of the rest and records them under one batch number. A date of 0001_01_01 sorts before any real one, so the skeleton’s tables always come first.

Pathshala’s three files build the fee part of its database schema: The shape of a database: its tables, their columns and types, and the indexes and keys that connect them. In Laravel the migrations build it, and db:table shows one table of it.. Their names put each table after the one it points at: students, then the instalments of a student, then the receipts of an instalment.

Pathshala's three fee migrations, in the order they run

PHP (2026_02_02_090000_create_students_table.php) · pathshala-fees/database/migrations/2026_02_02_090000_create_students_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * One row per student of the coaching centre.
     */
    public function up(): void
    {
        Schema::create('students', function (Blueprint $table) {
            $table->id();
            $table->string('name', 80);
            $table->string('roll_no', 12)->unique();
            $table->string('phone', 15)->nullable();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
     * The exact reverse of up(): the table goes, with every row in it.
     */
    public function down(): void
    {
        Schema::dropIfExists('students');
    }
};

PHP (2026_02_02_090100_create_fee_instalments_table.php) · pathshala-fees/database/migrations/2026_02_02_090100_create_fee_instalments_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * The instalments of each student's fee, with amounts in whole paise.
     */
    public function up(): void
    {
        Schema::create('fee_instalments', function (Blueprint $table) {
            $table->id();
            $table->foreignId('student_id')->constrained()->cascadeOnDelete();
            $table->unsignedTinyInteger('number');
            $table->unsignedInteger('amount_paise');
            $table->date('due_on');
            $table->date('paid_on')->nullable();
            $table->timestamps();

            // A student has one instalment 1, one instalment 2, and so on.
            $table->unique(['student_id', 'number']);
            // The office lists what falls due on a day.
            $table->index('due_on');
        });
    }

    /**
     * The exact reverse of up().
     */
    public function down(): void
    {
        Schema::dropIfExists('fee_instalments');
    }
};

PHP (2026_02_02_090200_create_fee_receipts_table.php) · pathshala-fees/database/migrations/2026_02_02_090200_create_fee_receipts_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * A receipt for each payment that settled an instalment.
     */
    public function up(): void
    {
        Schema::create('fee_receipts', function (Blueprint $table) {
            $table->id();
            // A paid instalment keeps its receipt: deleting the instalment is refused.
            $table->foreignId('fee_instalment_id')->constrained()->restrictOnDelete();
            $table->unsignedInteger('amount_paise');
            $table->string('gateway_reference', 40)->unique();
            $table->timestamps();
        });
    }

    /**
     * The exact reverse of up().
     */
    public function down(): void
    {
        Schema::dropIfExists('fee_receipts');
    }
};

Step through one run of migrate on a project whose skeleton tables already exist, and the rollback of that run:

Interactive · stage by stage What one php artisan migrate run does Step through one run on a project whose three skeleton tables already exist, then its rollback.
What one php artisan migrate run does, step 6 of 6. Showing List the files, Sort by file name, Skip recorded files, Run each up(), Record one batch. In focus: back.rollback: down()List the filesdatabase/migrationsSort by file nameskeleton files firstSkip recorded fileslisted in migrationsRun each up()one file at a timeRecord one batchall three in batch 2

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The folder is the list. migrate starts from the files in database/migrations: the three that came with the skeleton and the three Pathshala added, six in all. It never compares them with the tables the database already has.
  2. Name order is run order. The files are sorted by name, and every name starts with a date and a time. 0001_01_01_000000_create_users_table sorts before any real date, and students sorts before fee_instalments, which points at it.
  3. Recorded files are skipped. The migrations table already lists the skeleton's three files in batch 1, so they are skipped. A file whose name is in that table never runs again, even after you edit it.
  4. Each up() runs in turn. The three pending files run in name order: students, then fee_instalments, then fee_receipts. Each up() method creates its table.
  5. One batch for the whole run. All three are recorded with batch 2, the number migrate:status printed beside them. A second run that finds no new file records nothing.
  6. A rollback undoes a batch. migrate:rollback takes the last batch, 2, and calls the down() method of its files in reverse name order, so fee_receipts goes first and students last, and no table is dropped while another still points at it.

The session runs the three files, lists their status, rolls them back and lists the status again:

migrate, migrate:status and migrate:rollback on the fee tables Python · migrate_session.py
from session import run

# The pathshala-fees folder holds the three migrations the page shows; they are copied into a new project before the
# session starts, so migrate finds the skeleton's three files already run and these three pending.
run(
    """
php artisan migrate --no-interaction
php artisan migrate:status
php artisan migrate:rollback --no-interaction
php artisan migrate:status
""",
    app=True,
    overlay=("pathshala-fees",),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php artisan migrate:status
  Migration name .............................................. Batch / Status
  0001_01_01_000000_create_users_table ............................... [1] Ran
  0001_01_01_000001_create_cache_table ............................... [1] Ran
  0001_01_01_000002_create_jobs_table ................................ [1] Ran
  2026_02_02_090000_create_students_table ............................ [2] Ran
  2026_02_02_090100_create_fee_instalments_table ..................... [2] Ran
  2026_02_02_090200_create_fee_receipts_table ........................ [2] Ran
$ php artisan migrate:rollback --no-interaction
   INFO  Rolling back migrations.

  2026_02_02_090200_create_fee_receipts_table ........................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090000_create_students_table ............................... DONE
$ php artisan migrate:status
  Migration name .............................................. Batch / Status
  0001_01_01_000000_create_users_table ............................... [1] Ran
  0001_01_01_000001_create_cache_table ............................... [1] Ran
  0001_01_01_000002_create_jobs_table ................................ [1] Ran
  2026_02_02_090000_create_students_table ............................ Pending
  2026_02_02_090100_create_fee_instalments_table ..................... Pending
  2026_02_02_090200_create_fee_receipts_table ........................ Pending

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 migrate_session.py

migrate:status printed [1] Ran beside the skeleton’s files and [2] Ran beside Pathshala’s three. The rollback undid batch 2 and nothing else, in reverse name order, fee_receipts first and students last, so no table was dropped while another still pointed at it. All three went back to Pending, ready to run again. --step=1 rolls back only the last file instead of the whole batch.

Checkpoint

Check: Run order and batches

  1. Question 1 of 2 On a new machine, why does 0001_01_01_000000_create_users_table run before 2026_02_02_090000_create_students_table?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Pending migrations run in file-name order, and 0001_01_01 sorts before any real date

    The date and time at the start of every file name is the whole ordering scheme. migrate sorts the names, skips the files the migrations table already lists, and runs the rest in that order as one batch.

  2. Question 2 of 2 In the session, migrate:status showed the skeleton's files in batch 1 and Pathshala's three in batch 2. What does one php artisan migrate:rollback undo?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The three migrations of batch 2, fee_receipts first and students last

    A batch is one run of migrate. Rolling back undoes that run as a whole, newest file first, so a table is never dropped while another table still points at it.

Column types decide what a value can be, so money goes in whole paise

The closure of Schema::create receives a Blueprint: The object a Schema::create or Schema::table closure receives as $table. Each call on it, such as string('name', 80) or unique(['student_id', 'number']), adds a column, an index or a key to the table being built or changed., $table, and each call on it adds a column or a key. The instalments table uses six kinds:

  • id(), an auto-incrementing big integer primary key named id;
  • foreignId('student_id'), an unsigned big integer holding the key of another table (the next section);
  • unsignedTinyInteger('number') for the instalment’s number, and unsignedInteger('amount_paise') for its amount;
  • date('due_on'), and date('paid_on')->nullable() for a payment that has not happened yet;
  • timestamps(), the nullable created_at and updated_at that Eloquent fills in.

->nullable() is a column modifier: A call chained after a column type in a migration that changes how the column behaves, such as nullable(), default(0) or unsigned(). A change() keeps only the modifiers written in its own line., a call after the type that changes how the column behaves, as ->default(0) and ->unsigned() do. The students table adds softDeletes(), a nullable deleted_at column for a soft delete: Marking a row as deleted with a deleted_at time instead of removing it. Normal queries and route binding skip such rows, and they can be restored. More in “Controllers and route model binding”. Other types follow the same pattern: text() for long text, json() for a document, uuid() and ulid() for keys the application makes before the row is saved.

Money is where the type matters most. 1,500.10 has no exact form as a binary floating-point number, so three such instalments added as floats miss the fee:

paise.php: one fee as float rupees and as whole paise PHP · paise.php
<?php

// One term's fee of 4,500.30 rupees, paid in three equal instalments of 1,500.10.
// The same three amounts, added up as floats and as whole paise.

$inRupees = [1500.10, 1500.10, 1500.10];
$inPaise = [150010, 150010, 150010];

$rupeesTotal = array_sum($inRupees);
$paiseTotal = array_sum($inPaise);

echo 'float total:      ', var_export($rupeesTotal, true), PHP_EOL;
echo 'equal to 4500.30? ', var_export($rupeesTotal == 4500.30, true), PHP_EOL;
echo 'paise total:      ', $paiseTotal, PHP_EOL;
echo 'equal to 450030?  ', var_export($paiseTotal === 450030, true), PHP_EOL;
echo 'shown as rupees:  ', number_format(intdiv($paiseTotal, 100)), '.', sprintf('%02d', $paiseTotal % 100), PHP_EOL;

Output

float total:      4500.299999999999
equal to 4500.30? false
paise total:      450030
equal to 450030?  true
shown as rupees:  4,500.30

Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php paise.php

The float total printed 4500.299999999999, and comparing it with 4500.30 gave false; the PHP manual warns never to trust a float to its last digit or compare floats for equality. In paise each amount is the integer 150010, and the sum, 450030, is exact; the page turns it into 4,500.30 only when it shows it. A decimal column is exact on MySQL and PostgreSQL, but on SQLite Laravel creates a numeric column, which keeps 1500.10 as an 8-byte float. Whole paise in an integer column are exact everywhere.

Lengths differ too: SQLite ignores the 80 of string('name', 80), because it limits no string lengths, so test them on the database production uses. Ask for the SQL of one migration before it runs, then for the table it built:

The SQL of the instalments migration, then the table it built Python · schema_session.py
from session import run

# First the SQL that the fee_instalments migration would run (--pretend runs nothing), then the real run and the
# table as the database describes it.
run(
    """
php artisan migrate --pretend --path=database/migrations/2026_02_02_090100_create_fee_instalments_table.php
php artisan migrate --no-interaction
php artisan db:table fee_instalments
""",
    app=True,
    overlay=("pathshala-fees",),
)

Output

$ php artisan migrate --pretend --path=database/migrations/2026_02_02_090100_create_fee_instalments_table.php
   INFO  Running migrations.

  2026_02_02_090100_create_fee_instalments_table .............................
  ⇂ create table "fee_instalments" ("id" integer primary key autoincrement not null, "student_id" integer not null, "number" integer not null, "amount_paise" integer not null, "due_on" date not null, "paid_on" date, "created_at" datetime, "updated_at" datetime, foreign key("student_id") references "students"("id") on delete cascade)
  ⇂ create unique index "fee_instalments_student_id_number_unique" on "fee_instalments" ("student_id", "number")
  ⇂ create index "fee_instalments_due_on_index" on "fee_instalments" ("due_on")
$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php artisan db:table fee_instalments
  main.fee_instalments .......................................................
  Columns .................................................................. 8

  Column ................................................................ Type
  id integer, autoincrement .......................................... integer
  student_id integer ................................................. integer
  number integer ..................................................... integer
  amount_paise integer ............................................... integer
  due_on date ........................................................... date
  paid_on date, nullable ................................................ date
  created_at datetime, nullable ..................................... datetime
  updated_at datetime, nullable ..................................... datetime

  Index ......................................................................
  fee_instalments_due_on_index due_on ........................................
  fee_instalments_student_id_number_unique student_id, number  compound, unique
  primary id ......................................................... primary

  Foreign Key .......................................... On Update / On Delete
  student_id references id on students ................... no action / cascade

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 schema_session.py

--pretend printed one create table and two create index statements and ran none of them. After the real run, db:table read the table back: eight columns, with amount_paise integer and paid_on date, nullable, then two indexes and one foreign key, the subjects of the next two sections.

Checkpoint

Check: Column types and money in paise

  1. Question 1 of 2 An instalment of 2,999.70 rupees is saved in the unsignedInteger column amount_paise. Which number goes into the column?

    Type a number.

    A hint

    One rupee is 100 paise.

    Show the answer: Question 1 of 2

    Answer: 299970 paise

    2,999.70 rupees times 100 is 299,970 paise, stored as the integer 299970. The page turns it back into 2,999.70 only when it shows the amount, as paise.php did with 450030.

  2. Question 2 of 2 Why does Pathshala store amounts as whole paise in an integer column, not as rupees in a float column?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A float cannot hold 1,500.10 exactly, so three of them added up to 4500.299999999999, while integer paise add exactly

    Most decimal fractions have no exact binary floating-point form, so their sums drift in the last digits. Whole paise are integers, and integers add, compare and sum exactly.

A foreign key decides what happens to a student’s instalments

foreignId('student_id')->constrained() adds the column and makes it a foreign key: A column that holds the primary key of a row in another table, with a constraint that makes the database refuse a value matching no row there. foreignId('student_id')->constrained() creates one.. constrained() reads the column name, takes the id of the students table, and from then on the database refuses an instalment whose student_id matches no student. ->cascadeOnDelete() sets the rule for the other direction, the on delete cascade of the pretend output and the no action / cascade that db:table listed: deleting a student is a cascade delete: A foreign key rule, cascadeOnDelete() in a migration, under which deleting a parent row also deletes every row that points at it, in the same statement. that removes the student’s instalments too.

The receipts table takes the opposite rule. A receipt records money that was paid, so its key uses restrictOnDelete(), and the database refuses to delete an instalment a receipt points at. The probe deletes Asha, whose three instalments have no receipt, and tries Ravi, whose first instalment has one:

cascade.php: deleting a student with and without a receipt PHP · pathshala-fees/cascade.php
<?php

// Two students with three instalments each. Asha's instalments have no receipt; Ravi's first one does.
// What happens to the instalments when each student is deleted? Run it with: php cascade.php

use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;

require __DIR__.'/vendor/autoload.php';

$app = require_once __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

function student(string $name, string $rollNo): int
{
    $id = DB::table('students')->insertGetId(['name' => $name, 'roll_no' => $rollNo]);

    foreach ([1, 2, 3] as $number) {
        DB::table('fee_instalments')->insert([
            'student_id' => $id,
            'number' => $number,
            'amount_paise' => 150010,
            'due_on' => sprintf('2031-%02d-10', 3 + $number),
        ]);
    }

    return $id;
}

function instalments(int $studentId): int
{
    return DB::table('fee_instalments')->where('student_id', $studentId)->count();
}

$asha = student('Asha Verma', 'PS-0042');
echo 'Asha: ', instalments($asha), ' instalments', PHP_EOL;
DB::table('students')->where('id', $asha)->delete();
echo 'Asha deleted: ', instalments($asha), ' instalments left', PHP_EOL;

$ravi = student('Ravi Nair', 'PS-0043');
$first = DB::table('fee_instalments')->where('student_id', $ravi)->where('number', 1)->value('id');
DB::table('fee_receipts')->insert([
    'fee_instalment_id' => $first,
    'amount_paise' => 150010,
    'gateway_reference' => 'EXP-7731',
]);

try {
    DB::table('students')->where('id', $ravi)->delete();
} catch (QueryException $e) {
    echo 'Deleting Ravi refused: ', $e->errorInfo[2], PHP_EOL;
}
echo 'Ravi: ', DB::table('students')->where('id', $ravi)->count(), ' student row, ', instalments($ravi), ' instalments', PHP_EOL;

// What a soft delete does: it sets deleted_at and deletes nothing.
DB::table('students')->where('id', $ravi)->update(['deleted_at' => now()]);
echo 'Ravi soft-deleted: ', instalments($ravi), ' instalments', PHP_EOL;
What each delete did to the instalments Python · cascade_session.py
from session import run

# cascade.php (shown on the page) deletes one student without receipts and tries to delete one with a receipt.
run(
    """
php artisan migrate --no-interaction
php cascade.php
""",
    app=True,
    overlay=("pathshala-fees",),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php cascade.php
Asha: 3 instalments
Asha deleted: 0 instalments left
Deleting Ravi refused: FOREIGN KEY constraint failed
Ravi: 1 student row, 3 instalments
Ravi soft-deleted: 3 instalments

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cascade_session.py

Asha’s delete took her 3 instalments with it, leaving 0. Ravi’s was refused with FOREIGN KEY constraint failed: the cascade tried to delete his first instalment, the receipt’s rule refused that, so the whole statement failed and Ravi kept his row and all 3 instalments. The last line shows the gentler path, a soft delete, which only set deleted_at and kept every row.

SQLite starts with foreign keys off

SQLite’s own default is foreign key constraints switched off. Laravel’s SQLite connection switches them on unless DB_FOREIGN_KEYS=false is set; with them off, Asha’s delete would have left three instalments that point at nobody.

A third rule, nullOnDelete(), keeps the child rows and empties their key. The column must then be nullable, and every modifier goes before constrained(): foreignId('teacher_id')->nullable()->constrained()->nullOnDelete(). Answer the two questions about the child rows of your own table, and the table marks the rules that fit:

Interactive · answer and compare Which on-delete rule fits a child table? Answer two questions about the rows that point at a parent row, and the table marks the rules that fit.
Once the parent row is gone, does a child row still mean anything?
Must the child rows be kept, as payments and receipts must?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three foreign key rules, against what each does when a parent row is deleted
Rule Children survive the delete The parent delete succeeds Every child keeps its parent
Cascade In a migration: cascadeOnDelete() Poor deleted with the parent: Asha went from 3 instalments to 0 Good one statement removes the parent and its children Good no row is left pointing at a missing parent
Restrict In a migration: restrictOnDelete() Good untouched: Ravi kept all 3 instalments Poor refused while a child exists: deleting Ravi failed Good a child row always has its parent
Set null In a migration: nullable(), then nullOnDelete() Good kept, with an empty parent column Good the delete always succeeds Poor the kept rows no longer say whose they were
How the answers decide
  • “Must the child rows be kept, as payments and receipts must?” Yes, they are records to keep: rules out Cascade. A cascade deletes the children with the parent, so the records you must keep would go with it.
  • “Once the parent row is gone, does a child row still mean anything?” No, it only describes the parent: rules out Set null. A row that only describes its parent is a row about nobody once that column is empty.
  • “Once the parent row is gone, does a child row still mean anything?” No, it only describes the parent and “Must the child rows be kept, as payments and receipts must?” No, they may go: adds a caveat to Restrict. Restrict keeps everything safe, but every delete of a parent then needs its children removed first.
  • “Once the parent row is gone, does a child row still mean anything?” Yes, it stands on its own and “Must the child rows be kept, as payments and receipts must?” No, they may go: adds a caveat to Cascade. A cascade would also remove rows that still mean something on their own.
  • “Once the parent row is gone, does a child row still mean anything?” Yes, it stands on its own and “Must the child rows be kept, as payments and receipts must?” Yes, they are records to keep: adds a caveat to Set null. Set null keeps the record but forgets whose it was; keeping the parent with a soft delete keeps both.

Checkpoint

Check: Foreign keys and delete rules

  1. Question 1 of 2 Ravi's first instalment has a receipt, and fee_receipts uses restrictOnDelete(). What did deleting Ravi do in the session?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It was refused with FOREIGN KEY constraint failed, and Ravi kept his row and all 3 instalments

    Rules chain. Deleting a student cascades to the instalments, and deleting an instalment is restricted while a receipt points at it, so the student's delete fails as a whole.

  2. Question 2 of 2 An attendance mark must stay when the teacher who took it leaves and the teacher's row is deleted, with no teacher shown. Which column definition does that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: foreignId('teacher_id')->nullable()->constrained()->nullOnDelete()

    Set null keeps the child rows and empties their key, so the key column must be nullable, and nullable() goes before constrained(), as every column modifier does.

A unique index on two columns refuses the second instalment 2

db:table listed two indexes beside the primary key. A database index: A sorted copy of one or more columns that the database keeps beside a table, so it can find matching rows without reading every row. A unique index also refuses repeated values. keeps chosen columns in sorted order beside the table, so a lookup jumps to the matching rows instead of scanning all of them: index('due_on') serves the office’s list of what falls due on a day. unique(['student_id', 'number']) is a composite index: An index over two or more columns taken together, such as index(['student_id', 'number']). Unique, it refuses a repeated combination while each column on its own may repeat., and being unique it is also a unique constraint: A rule kept by the database that no two rows of a table may hold the same value, or the same combination of values, in the columns it covers. Laravel creates it as a unique index.: Asha may have instalments 1, 2 and 3, and every other student an instalment 2 of their own, but no student may hold two rows numbered 2. The probe saves Asha’s instalment 2 twice:

duplicates.php: one instalment saved twice, then two generated index names PHP · pathshala-fees/duplicates.php
<?php

// Saves instalment 2 of one student twice, then asks Laravel which names it would give two unique
// indexes that nobody named. Run it with: php duplicates.php

use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

require __DIR__.'/vendor/autoload.php';

$app = require_once __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

$asha = DB::table('students')->insertGetId(['name' => 'Asha Verma', 'roll_no' => 'PS-0042']);
$second = ['student_id' => $asha, 'number' => 2, 'amount_paise' => 150010, 'due_on' => '2031-05-10'];

DB::table('fee_instalments')->insert($second);
echo 'Instalment 2 saved', PHP_EOL;

try {
    DB::table('fee_instalments')->insert($second);
} catch (QueryException $e) {
    echo 'Saving it again: ', $e->errorInfo[2], PHP_EOL;
}
echo 'Rows for Asha: ', DB::table('fee_instalments')->where('student_id', $asha)->count(), PHP_EOL;

// With no name given, Laravel joins the table, the columns and the index type with underscores.
foreach ([
    'fee_instalments' => ['student_id', 'number'],
    'instalment_reminder_deliveries' => ['fee_instalment_id', 'channel', 'scheduled_for'],
] as $table => $columns) {
    $name = (new Blueprint(Schema::getConnection(), $table))->unique($columns)->index;
    echo $name, ': ', strlen($name), ' characters', PHP_EOL;
}
The second save, refused, and the names Laravel generates Python · duplicates_session.py
from session import run

# duplicates.php (shown on the page) saves one instalment twice and prints the index names Laravel would generate.
run(
    """
php artisan migrate --no-interaction
php duplicates.php
""",
    app=True,
    overlay=("pathshala-fees",),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php duplicates.php
Instalment 2 saved
Saving it again: UNIQUE constraint failed: fee_instalments.student_id, fee_instalments.number
Rows for Asha: 1
fee_instalments_student_id_number_unique: 40 characters
instalment_reminder_deliveries_fee_instalment_id_channel_scheduled_for_unique: 77 characters

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 duplicates_session.py

The second insert failed with UNIQUE constraint failed: fee_instalments.student_id, fee_instalments.number, and Asha still had 1 row. A form’s validation can check the same rule, but only the index still holds when two requests arrive at once or a script writes to the table directly.

The last two lines are the names Laravel gives an index nobody named: the table, the columns and the type, joined with underscores. fee_instalments_student_id_number_unique has 40 characters. A unique key over three columns of an instalment_reminder_deliveries table comes to 77, and MySQL refuses an index or constraint name longer than 64, so a migration that runs on your SQLite copy can fail on a MySQL server. Give long ones a name as the second argument: unique(['fee_instalment_id', 'channel', 'scheduled_for'], 'reminder_slot_unique'). fullText('body') names its index the same way; it works on MariaDB, MySQL and PostgreSQL.

SQLite Online (SQL Playground) Paste the create table and create index statements of the pretend run, then insert one instalment twice.

Key idea

Rules the data must never break belong in the schema: a foreign key and a unique index keep holding when two requests race or a script skips the application’s checks, and a long index name is given by hand.

Checkpoint

Check: Unique and composite indexes

  1. Question 1 of 2 What did the session's second insert of Asha's instalment 2 get?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: UNIQUE constraint failed: fee_instalments.student_id, fee_instalments.number, and Asha kept 1 row

    unique(['student_id', 'number']) refuses a repeated combination. Asha may have instalments 1, 2 and 3, and other students their own instalment 2, but nobody gets two rows numbered 2.

  2. Question 2 of 2 A migration adds unique(['fee_instalment_id', 'channel', 'scheduled_for']) to instalment_reminder_deliveries, and production runs MySQL. Why give the index a name of your own?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The generated name has 77 characters, and MySQL refuses index names longer than 64

    A generated name grows with the table and column names. Pass a short name as the second argument whenever it could pass 64 characters, so the migration runs on MySQL as well as on SQLite.

A changed column keeps only the modifiers you repeat

Some parents give numbers with a country code, which need up to 20 characters, so a later migration widens phone. The column was string('phone', 15)->nullable(), and the new definition says only string('phone', 20)->change():

The widening migration, without nullable() PHP · phone-change/later/2026_03_02_090000_widen_student_phone.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Numbers written with a country code need up to 20 characters.
     */
    public function up(): void
    {
        Schema::table('students', function (Blueprint $table) {
            $table->string('phone', 20)->change();
        });
    }

    /**
     * Back to the definition the column had before.
     */
    public function down(): void
    {
        Schema::table('students', function (Blueprint $table) {
            $table->string('phone', 15)->nullable()->change();
        });
    }
};

change() does not edit the old definition; it replaces it with the one written here, and Laravel’s documentation says any modifier missing from it is dropped, here nullable(). SQLite has no statement that changes a column’s type or length, so Laravel rebuilds the table. In the session, rows.php --seed first adds three students without a phone, with three instalments each, and prints the counts:

The widening migration on three students without a phone Python · change_column_session.py
from session import run

# Three students without a phone number, then a migration that widens phone with change() and forgets nullable().
run(
    """
php artisan migrate --no-interaction
php rows.php --seed
cp later/2026_03_02_090000_widen_student_phone.php database/migrations/
!php artisan migrate --no-interaction
php artisan migrate:status
sqlite3 database/database.sqlite .tables
""",
    app=True,
    overlay=("pathshala-fees", "phone-change"),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php rows.php --seed
students: 3, instalments: 9
$ cp later/2026_03_02_090000_widen_student_phone.php database/migrations/
$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_03_02_090000_widen_student_phone ................................. FAIL

   Illuminate\Database\QueryException

  SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: __temp__students.phone (Connection: sqlite, Database: database/database.sqlite, SQL: insert into "__temp__students" ("id", "name", "roll_no", "phone", "created_at", "updated_at", "deleted_at") select "id", "name", "roll_no", "phone", "created_at", "updated_at", "deleted_at" from "students")

  at vendor/laravel/framework/src/Illuminate/Database/Connection.php:857
    853▕             $exceptionType = ($isUniqueConstraintError = $this->isUniqueConstraintError($e))
    854▕                 ? UniqueConstraintViolationException::class
    855▕                 : QueryException::class;
    856▕
  ➜ 857▕             $exception = new $exceptionType(
    858▕                 $this->getNameWithReadWriteType(),
    859▕                 $query,
    860▕                 $this->prepareBindings($bindings),
    861▕                 $e,

      +9 vendor frames

  10  database/migrations/2026_03_02_090000_widen_student_phone.php:14
      Illuminate\Support\Facades\Facade::__callStatic("table")
      +26 vendor frames

  37  artisan:16
      Illuminate\Foundation\Application::handleCommand(Object(Symfony\Component\Console\Input\ArgvInput))
[exit status 1]
$ php artisan migrate:status
  Migration name .............................................. Batch / Status
  0001_01_01_000000_create_users_table ............................... [1] Ran
  0001_01_01_000001_create_cache_table ............................... [1] Ran
  0001_01_01_000002_create_jobs_table ................................ [1] Ran
  2026_02_02_090000_create_students_table ............................ [2] Ran
  2026_02_02_090100_create_fee_instalments_table ..................... [2] Ran
  2026_02_02_090200_create_fee_receipts_table ........................ [2] Ran
  2026_03_02_090000_widen_student_phone .............................. Pending
$ sqlite3 database/database.sqlite .tables
__temp__students       fee_receipts           sessions
cache                  job_batches            students
cache_locks            jobs                   users
failed_jobs            migrations
fee_instalments        password_reset_tokens

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 change_column_session.py

It failed on its copy step, the insert into "__temp__students" … select … from "students", with NOT NULL constraint failed: __temp__students.phone. Step through what Laravel did, up to that line and after the fix:

Interactive · step by step What change() does to the students table on SQLite Step through the forgetful migration on three students without a phone, then the corrected one.
What change() does to the students table on SQLite, step 7 of 7. Showing __temp__students (now named students). In focus: __temp__students.__temp__studentsnow named students

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. change() reads the table. Laravel asks SQLite for every column, index and foreign key of students. Its phone column is nullable, and none of the three students in the table has a phone number.
  2. A new table, a new phone. Laravel builds the changed table as a new one, __temp__students, with every column of students and the definition of phone from the migration: string(20) with no nullable(), so not null.
  3. One copy of every row. A single insert … select copies the rows. The first student has no phone, the new column refuses NULL, and SQLite stops the copy with NOT NULL constraint failed: __temp__students.phone.
  4. Stopped half-way. Nothing after the copy runs. students keeps its rows, migrate:status still lists the migration as pending, and the empty __temp__students table stays in the database, as .tables listed it.
  5. The next run trips over it. The corrected file starts with the same create table __temp__students, which now fails because that table already exists. Dropping the leftover table by hand clears the way.
  6. With nullable() repeated. The corrected definition keeps phone nullable, so the new table accepts students without a phone, and all three rows are copied.
  7. Drop, rename, index again. Laravel drops the old students table, renames __temp__students to students and creates the unique index on roll_no again. db:table then showed phone as nullable, and the count was still 3 students and 9 instalments.

Two things in that output need care. The migration stayed Pending, but the half-built __temp__students stayed too: Laravel wraps a migration in a transaction only on PostgreSQL and SQL Server, so on SQLite nothing undid the steps that had run, and .tables listed the leftover. And the failure was luck: with a phone on every row, the same migration would have succeeded and quietly made phone required for the next student. The corrected file repeats the modifier:

The widening migration, with nullable() repeated PHP · phone-change/fixed/2026_03_02_090000_widen_student_phone.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Numbers written with a country code need up to 20 characters. The column stays nullable,
     * because change() keeps only the modifiers listed here.
     */
    public function up(): void
    {
        Schema::table('students', function (Blueprint $table) {
            $table->string('phone', 20)->nullable()->change();
        });
    }

    /**
     * Back to the definition the column had before.
     */
    public function down(): void
    {
        Schema::table('students', function (Blueprint $table) {
            $table->string('phone', 15)->nullable()->change();
        });
    }
};

The next session repeats the failed run, then runs this file on top of what that run left behind:

The corrected migration, before and after the leftover table goes Python · change_fixed_session.py
from session import run

# The failed run of the previous session again (its output is not shown a second time), then the corrected file:
# first on top of the table the failed run left behind, then after dropping that table.
run(
    """
php artisan migrate --no-interaction
php rows.php --seed
cp later/2026_03_02_090000_widen_student_phone.php database/migrations/
!php artisan migrate --no-interaction > /dev/null
cp fixed/2026_03_02_090000_widen_student_phone.php database/migrations/
!php artisan migrate --no-interaction
sqlite3 database/database.sqlite "drop table __temp__students"
php artisan migrate --no-interaction
php artisan db:table students
php rows.php
""",
    app=True,
    overlay=("pathshala-fees", "phone-change"),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php rows.php --seed
students: 3, instalments: 9
$ cp later/2026_03_02_090000_widen_student_phone.php database/migrations/
$ php artisan migrate --no-interaction > /dev/null
[exit status 1]
$ cp fixed/2026_03_02_090000_widen_student_phone.php database/migrations/
$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_03_02_090000_widen_student_phone ................................. FAIL

   Illuminate\Database\QueryException

  SQLSTATE[HY000]: General error: 1 table "__temp__students" already exists (Connection: sqlite, Database: database/database.sqlite, SQL: create table "__temp__students" ("id" integer primary key autoincrement not null, "name" varchar not null, "roll_no" varchar not null, "phone" varchar, "created_at" datetime, "updated_at" datetime, "deleted_at" datetime))

  at vendor/laravel/framework/src/Illuminate/Database/Connection.php:857
    853▕             $exceptionType = ($isUniqueConstraintError = $this->isUniqueConstraintError($e))
    854▕                 ? UniqueConstraintViolationException::class
    855▕                 : QueryException::class;
    856▕
  ➜ 857▕             $exception = new $exceptionType(
    858▕                 $this->getNameWithReadWriteType(),
    859▕                 $query,
    860▕                 $this->prepareBindings($bindings),
    861▕                 $e,

      +9 vendor frames

  10  database/migrations/2026_03_02_090000_widen_student_phone.php:15
      Illuminate\Support\Facades\Facade::__callStatic("table")
      +26 vendor frames

  37  artisan:16
      Illuminate\Foundation\Application::handleCommand(Object(Symfony\Component\Console\Input\ArgvInput))
[exit status 1]
$ sqlite3 database/database.sqlite "drop table __temp__students"
$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_03_02_090000_widen_student_phone ................................. DONE
$ php artisan db:table students
  main.students ..............................................................
  Columns .................................................................. 7

  Column ................................................................ Type
  id integer, autoincrement .......................................... integer
  name varchar ....................................................... varchar
  roll_no varchar .................................................... varchar
  phone varchar, nullable ............................................ varchar
  created_at datetime, nullable ..................................... datetime
  updated_at datetime, nullable ..................................... datetime
  deleted_at datetime, nullable ..................................... datetime

  Index ......................................................................
  primary id ......................................................... primary
  students_roll_no_unique roll_no ..................................... unique
$ php rows.php
students: 3, instalments: 9

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 change_fixed_session.py

It failed once more, on table "__temp__students" already exists, until the leftover was dropped. Then it ran, db:table showed phone varchar, nullable, and the count was still 3 students and 9 instalments: Laravel switches foreign keys off for the rebuild, so dropping the old students table did not cascade into the instalments.

dropColumn on SQLite 3.35 and later

Laravel 13 rebuilds a SQLite table for change(), a new foreign key and a new primary key. dropColumn() uses SQLite’s own ALTER TABLE … DROP COLUMN instead, which rewrites the table’s content and refuses a column that is indexed, unique or part of a foreign key; drop that index or key first.

So a change is a full definition, never a patch: read the column in db:table before you write its new line, and run the migration on a copy that holds real rows before it reaches production.

Key idea

change() writes the whole column again from the line you give it, so list every modifier the column must keep.

Checkpoint

Check: Changing a column safely

  1. Question 1 of 2 A column was created as integer('seats')->unsigned()->default(30). A later migration runs integer('seats')->default(30)->change(). What is the column afterwards?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A signed integer with default 30: unsigned() was not repeated, so it was dropped

    Write a change() line as the column's complete new definition: the type, the length and every modifier it should keep, unsigned(), nullable(), default() and the rest.

  2. Question 2 of 2 After the forgetful phone migration failed on SQLite, the corrected file failed with table "__temp__students" already exists. Why, and what cleared it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The failed rebuild had created __temp__students and stopped at the copy, nothing undid it, and dropping that table cleared the way

    A rebuild is four statements: create the new table, copy the rows, drop the old table, rename the new one. On SQLite a failure in the copy leaves the first step done, and the next attempt trips over it.

A schema dump replaces a long folder of migrations

A project that has run for years can hold hundreds of migrations, and every new copy runs all of them to build its tables. php artisan schema:dump writes the current schema into one file instead, a schema dump: A SQL file in database/schema, written by php artisan schema:dump, that holds the current tables and the list of migrations already run. On an empty database migrate loads it first, then runs only newer migrations. in database/schema, named after the connection; --prune also deletes the migration files it replaces. The session dumps the project’s six migrations, adds the corrected phone migration as a file written after the dump, and builds an empty database:

schema:dump --prune, then a new database built from the dump Python · dump_session.py
from session import run

# Squashes every migration of the project into one SQL file, then builds a new, empty database from that file and
# from one migration written after the dump (the corrected phone change of the phone-change folder).
run(
    """
php artisan migrate --no-interaction
php artisan schema:dump --prune
ls database database/schema
grep -c "CREATE TABLE" database/schema/sqlite-schema.sql
grep -c "^INSERT INTO" database/schema/sqlite-schema.sql
mkdir database/migrations
cp fixed/2026_03_02_090000_widen_student_phone.php database/migrations/
rm database/database.sqlite
touch database/database.sqlite
php artisan migrate --no-interaction
""",
    app=True,
    overlay=("pathshala-fees", "phone-change"),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php artisan schema:dump --prune
   INFO  Database schema dumped and pruned successfully.
$ ls database database/schema
database:
database.sqlite
factories
schema
seeders

database/schema:
sqlite-schema.sql
$ grep -c "CREATE TABLE" database/schema/sqlite-schema.sql
12
$ grep -c "^INSERT INTO" database/schema/sqlite-schema.sql
6
$ mkdir database/migrations
$ cp fixed/2026_03_02_090000_widen_student_phone.php database/migrations/
$ rm database/database.sqlite
$ touch database/database.sqlite
$ php artisan migrate --no-interaction
   INFO  Preparing database.

  Creating migration table .............................................. DONE

   INFO  Loading stored database schemas.

  database/schema/sqlite-schema.sql ..................................... DONE

   INFO  Running migrations.

  2026_03_02_090000_widen_student_phone ................................. DONE

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dump_session.py

--prune removed the whole database/migrations folder. sqlite-schema.sql held 12 CREATE TABLE statements, for the eight tables of the skeleton’s migrations, Pathshala’s three and the migrations table, plus 6 INSERT lines that record the six migrations it stands for. On the empty database, migrate loaded that file first and then ran only the migration written after the dump.

Commit the file, so a new teammate builds the tables from it, and when the tests use another connection dump that one too, with --database=testing. The command calls the database’s own command-line client, sqlite3 here, and works for MariaDB, MySQL, PostgreSQL and SQLite.

Read a dump before you prune

In a copy of your project, run php artisan schema:dump without --prune and open the file. It is plain SQL: every table, index and foreign key the migrations built, readable in one place.

Checkpoint

Check: Squashing with a schema dump

  1. Question 1 of 2 After schema:dump --prune, a teammate runs php artisan migrate on an empty database. What runs?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The SQL of database/schema/sqlite-schema.sql, then only the migrations written after the dump

    An empty database is built in two steps: the stored schema, then the migrations the dump does not cover. The dump also carries the rows of the migrations table, so the old files count as already run.

  2. Question 2 of 2 What did --prune do in the session?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It deleted the migration files the dump replaced, and the database/migrations folder with them

    schema:dump writes the file, and --prune deletes the migrations it replaces. The next migration needs the database/migrations folder again, which is why the session created it before copying the phone change in.

Production migrations run without a question, so guard what they can delete

migrate:fresh drops every table of the connection and runs every migration from the first, which rebuilds a development database in seconds. On a database that holds rows, it ends them:

migrate:fresh on a copy that holds three students Python · fresh_session.py
from session import run

# On a development copy: nine instalment rows, then migrate:fresh.
run(
    """
php artisan migrate --no-interaction
php rows.php --seed
php artisan migrate:fresh --no-interaction
php rows.php
""",
    app=True,
    overlay=("pathshala-fees",),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php rows.php --seed
students: 3, instalments: 9
$ php artisan migrate:fresh --no-interaction
  Dropping all tables ................................................... DONE

   INFO  Preparing database.

  Creating migration table .............................................. DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table .................................. DONE
  0001_01_01_000001_create_cache_table .................................. DONE
  0001_01_01_000002_create_jobs_table ................................... DONE
  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php rows.php
students: 0, instalments: 0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fresh_session.py

Three students and nine instalments went to zero and zero, and nothing asked first, because the project said APP_ENV=local. migrate:reset and migrate:refresh remove the data too, by running every down() method. In production, Laravel asks for confirmation before migrate and the destructive commands, and a deploy script answers that question with --force. The session sets APP_ENV=production and types the commands with nobody there to answer:

migrate and migrate:fresh with APP_ENV=production Python · production_session.py
from session import run

# The same project told that it is the production copy (APP_ENV=production): the commands of a deploy, typed with
# nobody there to answer a question.
run(
    """
php artisan migrate --no-interaction
php rows.php --seed
export APP_ENV=production
!php artisan migrate --no-interaction
php artisan migrate --force
!php artisan migrate:fresh --no-interaction
""",
    app=True,
    overlay=("pathshala-fees",),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php rows.php --seed
students: 3, instalments: 9
$ export APP_ENV=production
$ php artisan migrate --no-interaction
                           APPLICATION IN PRODUCTION.


   WARN  Command cancelled.
[exit status 1]
$ php artisan migrate --force
   INFO  Nothing to migrate.
$ php artisan migrate:fresh --no-interaction
                           APPLICATION IN PRODUCTION.


   WARN  Command cancelled.
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 production_session.py

With --no-interaction and nobody to answer, migrate printed APPLICATION IN PRODUCTION. and Command cancelled. and stopped with exit status 1, and so did migrate:fresh. migrate --force skipped the question and ran, finding nothing to migrate. That is the danger: --force removes the only question, so every pending file runs as written, and migrate:fresh --force would drop every table. One line in AppServiceProvider closes that door:

AppServiceProvider: destructive commands refused in production PHP · guarded/later/AppServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        // In production, db:wipe and migrate:fresh, refresh, reset and rollback refuse to run, even with --force.
        DB::prohibitDestructiveCommands($this->app->isProduction());
    }
}

The session copies that file into the production copy of the project and runs migrate:fresh --force:

migrate:fresh --force on the production copy, with the guard Python · guard_session.py
from session import run

# The production copy again, now with the AppServiceProvider of the guarded folder, which prohibits destructive
# commands in production, and migrate:fresh with --force.
run(
    """
php artisan migrate --no-interaction
php rows.php --seed
export APP_ENV=production
cp later/AppServiceProvider.php app/Providers/AppServiceProvider.php
!php artisan migrate:fresh --force
php rows.php
""",
    app=True,
    overlay=("pathshala-fees", "guarded"),
)

Output

$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_02_02_090000_create_students_table ............................... DONE
  2026_02_02_090100_create_fee_instalments_table ........................ DONE
  2026_02_02_090200_create_fee_receipts_table ........................... DONE
$ php rows.php --seed
students: 3, instalments: 9
$ export APP_ENV=production
$ cp later/AppServiceProvider.php app/Providers/AppServiceProvider.php
$ php artisan migrate:fresh --force
   WARN  This command is prohibited from running in this environment.
[exit status 1]
$ php rows.php
students: 3, instalments: 9

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 guard_session.py

With DB::prohibitDestructiveCommands($this->app->isProduction()) in place, migrate:fresh --force was refused with This command is prohibited from running in this environment, and the rows were still there, 3 and 9. The guard refuses db:wipe, migrate:fresh, migrate:refresh, migrate:reset and migrate:rollback, and leaves migrate alone.

It stops the worst commands, not a bad migration. Three habits cover the rest:

  • Take a backup before the deploy’s migrate --force, and restore it once on another machine to know that it works.
  • Keep each migration compatible with the code that still runs while the deploy rolls out: a new column is nullable or has a default, because the old code inserts rows without it, and a column goes only once no running code reads it.
  • Fix a mistake with a new migration rather than a rollback, because a down() that drops a column or a table drops the data in it.

Key idea

In production --force removes the last question, so the safety must exist before the command runs: a tested backup, a guard against destructive commands, and migrations the old code can live with.

Checkpoint

Check: Migrations in production

  1. Question 1 of 2 A deploy script runs php artisan migrate --force on a server with APP_ENV=production. What does --force change?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It skips the confirmation question, so every pending migration runs without anyone answering it

    In production Laravel asks before it migrates, and a deploy has nobody to answer, so it passes --force. From then on, whatever the pending files say is what happens to the data.

  2. Question 2 of 2 AppServiceProvider calls DB::prohibitDestructiveCommands($this->app->isProduction()). Someone runs php artisan migrate:fresh --force on the production server. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It is refused with This command is prohibited from running in this environment, and every row stays

    The guard refuses the commands that drop or undo tables whenever the application is in production, with or without --force, and leaves the deploy's plain migrate alone.

Practise: an attendance register for Pathshala’s batches

Build the register in your own copy of the project, one migration at a time:

  1. Create a migration for class_sessions with id(), string('batch', 40), date('held_on') and timestamps(). Run it and look for its batch number in php artisan migrate:status.
  2. Create attendance_marks with foreignId('class_session_id'), foreignId('student_id'), a status that holds present, absent or late, and timestamps(). Choose each key’s delete rule with the trade-off table: should a mark outlive its session, and its student?
  3. Add unique(['class_session_id', 'student_id']) with a name of your own, run php artisan db:table attendance_marks, and find the index and both foreign keys in what it prints.
  4. Write a script like duplicates.php that marks one student twice for one session, and check that the second insert fails with UNIQUE constraint failed.
  5. Roll back with php artisan migrate:rollback, check that the file shows Pending, and migrate again.
  6. Add a nullable note column in a new migration, widen it with change(), and confirm in db:table that it is still nullable.

Then test the seven ideas of the lesson. Your weakest idea is asked about first, and each choice explains itself as soon as you answer:

Practice that adapts to you

Practise this lesson

20 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 20 What does the date and time at the start of a migration's file name decide?

    Choose one answer.

    Show the answer: Question 1 of 20

    Answer: Where the file falls in the order that migrate runs pending files

    make:migration puts the moment of creation at the front of the name. Sorted names then put every change after the changes it was written to follow.

  2. Question 2 of 20 The last migrate ran three new files as one batch, and only the newest of them is wrong. Which command undoes that one file and leaves the other two in place?

    Choose one answer.

    Show the answer: Question 2 of 20

    Answer: php artisan migrate:rollback --step=1

    A batch is the unit of a plain rollback. --step=N rolls back the last N files instead, which is useful while you are still shaping the newest one.

  3. Question 3 of 20 Two teammates add migrations on the same morning. The one that creates class_sessions starts with 2026_04_06_101500, and the one that creates attendance_marks, which points at class_sessions, starts with 2026_04_06_093000. What happens on a new database, and what is the fix?

    Choose one answer.

    Show the answer: Question 3 of 20

    Answer: attendance_marks runs first, before the table its key points at exists, so rename it to sort after class_sessions

    A migration that points at another table must sort after the migration that creates it. When names collide in the wrong order, rename the newer file before anyone has run it.

  4. Question 4 of 20 Why is paid_on declared with nullable() while due_on is not?

    Choose one answer.

    Show the answer: Question 4 of 20

    Answer: Every instalment has a due date from the start, but no payment date until it is paid

    A column modifier states a fact about the data: nullable() says the value may be missing. Use it where missing means something, as unpaid does here.

  5. Question 5 of 20 A test on SQLite saves a 100-character name into a column created with string('name', 80), and the test passes. What does that prove about the length rule?

    Choose one answer.

    Show the answer: Question 5 of 20

    Answer: Nothing, because SQLite ignores the 80 and limits no string lengths

    db:table showed name as a plain varchar on SQLite. Check lengths in validation, and in tests that run on the same database engine as production.

  6. Question 6 of 20 A teammate prefers decimal('amount', 10, 2) to integer paise, because MySQL stores DECIMAL exactly. The tests run on SQLite. What is the catch?

    Choose one answer.

    Show the answer: Question 6 of 20

    Answer: On SQLite Laravel creates a numeric column that keeps 1500.10 as an 8-byte float, so the tests do different arithmetic from production

    Integer paise are exact on every database Laravel supports and in PHP's integer arithmetic, so the tests on SQLite and production on MySQL add money the same way.

  7. Question 7 of 20 What does constrained() add to foreignId('student_id')?

    Choose one answer.

    Show the answer: Question 7 of 20

    Answer: A foreign key to the id column of the students table, worked out from the column name

    constrained() follows the naming convention, student_id to students.id. When a table does not follow it, pass the table's name to constrained() yourself.

  8. Question 8 of 20 Which delete rule suits fee_receipts.fee_instalment_id, and why?

    Choose one answer.

    Show the answer: Question 8 of 20

    Answer: Restrict, because a receipt records money that was paid and must outlive any attempt to delete its instalment

    Choose the rule from what a child row means without its parent: receipts are records to keep, so the database should refuse the delete and leave the decision to a person.

  9. Question 9 of 20 A project sets DB_FOREIGN_KEYS=false for its SQLite connection. A student with 3 instalments is deleted. What is left in fee_instalments?

    Choose one answer.

    Show the answer: Question 9 of 20

    Answer: 3 instalments whose student_id points at no student

    SQLite's own default is foreign keys off. Laravel switches them on for SQLite unless DB_FOREIGN_KEYS=false, and every rule of this lesson depends on that.

  10. Question 10 of 20 What does index('due_on') do for Pathshala's office?

    Choose one answer.

    Show the answer: Question 10 of 20

    Answer: It lets the database find the instalments due on a day without reading every row

    A plain index speeds up finding rows and allows duplicates. A unique index does the same and also refuses a repeated value or combination.

  11. Question 11 of 20 With unique(['student_id', 'number']) on fee_instalments, which insert is refused?

    Choose one answer.

    Show the answer: Question 11 of 20

    Answer: A second instalment 2 for Asha

    A composite unique index looks at the combination. Each column on its own may repeat; the pair of values may not.

  12. Question 12 of 20 The instalment form already validates that the number is not taken for the student. Why keep the unique index as well?

    Choose one answer.

    Show the answer: Question 12 of 20

    Answer: Two requests can pass the check at the same moment, and scripts can skip validation; the index refuses the second row in every case

    Validation gives the visitor a friendly message. The unique index is what keeps the rule true when the application's checks are raced or bypassed.

  13. Question 13 of 20 What does change() do with a column's existing definition?

    Choose one answer.

    Show the answer: Question 13 of 20

    Answer: It replaces it with the definition written in the same line, and any modifier left out is dropped

    Treat a change() line as a full column definition: type, length and every modifier it should keep.

  14. Question 14 of 20 Why did changing one column on SQLite create a table named __temp__students?

    Choose one answer.

    Show the answer: Question 14 of 20

    Answer: SQLite has no statement that changes a column's type or length, so Laravel builds a new table, copies the rows, drops the old one and renames the new one

    On SQLite, change(), a new foreign key and a new primary key are applied by rebuilding the table, which is why they take time on a big table and why a failure can leave the temporary table behind.

  15. Question 15 of 20 The phone migration without nullable() runs on a copy where every student has a phone number. What happens, and why is that worse than the failure in the lesson?

    Choose one answer.

    Show the answer: Question 15 of 20

    Answer: It succeeds and makes phone required, so the next student saved without a phone fails, long after the migration

    A failing migration is a visible problem; a succeeding one with a lost modifier is a silent one. Read the column in db:table after a change, not only the migration's DONE.

  16. Question 16 of 20 The project's tests use a second connection named testing. Following Laravel's documentation, how do you dump the schema so the tests can still build their tables?

    Choose one answer.

    Show the answer: Question 16 of 20

    Answer: Run php artisan schema:dump, then php artisan schema:dump --database=testing --prune

    A dump belongs to one connection. When tests use another, dump that connection too before the migrations are pruned.

  17. Question 17 of 20 The migrations were pruned after a dump. A new teammate clones the project, and migrate on an empty database fails at the first newer migration with no such table, students. Which step was skipped?

    Choose one answer.

    Show the answer: Question 17 of 20

    Answer: The schema file was never committed, so the clone has no dump to load before the newer migration

    A pruned project stands on two things in version control: the schema file and the migrations written after it. Laravel's documentation asks for the file to be committed for exactly this reason.

  18. Question 18 of 20 What does php artisan migrate:fresh do?

    Choose one answer.

    Show the answer: Question 18 of 20

    Answer: It drops every table of the connection and then runs every migration from the first

    migrate:fresh is a development tool: it rebuilds the whole database from the migrations, and every row is gone afterwards.

  19. Question 19 of 20 A migration ran in production yesterday and created a column with the wrong type, and rows have been written to it since. What is the safe fix?

    Choose one answer.

    Show the answer: Question 19 of 20

    Answer: Take a backup, then write a new migration that changes the column, and deploy it

    In production a schema only moves forward. Each fix is a new migration, deployed like any other, after a backup you have restored once.

  20. Question 20 of 20 During a deploy the old code keeps inserting students for about two minutes. A migration in that deploy adds a guardian_name column. Which definition keeps the old code working?

    Choose one answer.

    Show the answer: Question 20 of 20

    Answer: string('guardian_name', 80)->nullable(), because the old code's inserts leave the column out

    A migration must suit the code before it and the code after it. A new column starts nullable or with a default; a later migration can tighten it once every row and every server is ready.

Interview questions

Warm-up (fresher): migrate:rollback and migrate:fresh both take tables away. How far does each one go?

migrate:rollback undoes the last batch, the files that the most recent migrate ran. It calls their down() methods in reverse name order, removes them from the migrations table and leaves earlier batches alone; --step=N undoes the last N files instead. Because it runs your own down() methods, it does exactly what they say, and a down() that drops a table drops its rows. migrate:fresh calls no down() method at all: it drops every table on the connection, whatever created it, and then runs every migration from the first, so the database ends with the right tables and no rows. Rollback suits the migration you are still shaping; fresh rebuilds a development database from nothing. Neither belongs on a production database that holds real rows.

Two harder questions of this module begin where this lesson ends: a rename that must not break the code still running, and a deploy that adds a unique index to a table that already holds duplicates. Plan your answer to each before you open its first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Pathshala wants to rename fee_instalments.due_on to due_date. The app runs on two servers, and each deploy replaces them one at a time over about five minutes, so old and new code serve requests together. Plan the change so that no request fails: how many deploys does it take, and what does each one change?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A deploy adds unique(['student_id', 'number']) to a production fee_instalments table of 400,000 rows. Every test passed, but on the server the migration fails with a UNIQUE constraint error. Why did the tests miss it, what state is production in now, and how do you ship the index safely?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. In which order does php artisan migrate run files, and what does migrate:rollback undo?

    migrate runs the pending files of database/migrations in file-name order as one batch. migrate:rollback calls the down() methods of the last batch in reverse order and leaves earlier batches alone.

  2. How far do migrate:rollback and migrate:fresh each go?

    rollback undoes only the last batch through your own down() methods (or the last N files with --step). fresh calls no down() method at all: it drops every table on the connection and runs every migration again.

  3. Why does Pathshala store fees in an integer column of paise rather than as a float?

    Floats hold most decimal fractions only approximately: three instalments of 1,500.10 added up to 4500.299999999999, while three times 150010 paise is exactly 450030.

  4. What do cascadeOnDelete() and restrictOnDelete() on a foreign key do when a student is deleted?

    cascadeOnDelete() deletes the child rows with the parent, so Asha's three instalments went with her. restrictOnDelete() refuses the parent's delete while child rows exist, as receipts refused Ravi's delete. A soft delete keeps every row.

  5. How does the schema stop a student from getting two instalments numbered 2?

    A unique index over student_id and number refuses the second row, even when two requests race. Generated index names can pass MySQL's 64-character limit, so give long ones a name by hand.

  6. Why did widening a phone column with change() fail on the first student without a phone?

    change() writes the whole column again from the line you give it, and that line forgot nullable(). List every modifier the column must keep. On SQLite the failed change also left a __temp__students table behind.

  7. What protects a production database when migrations run with --force?

    --force skips the only confirmation question, so the protection must exist beforehand: a tested backup, a guard against destructive commands, and migrations that the old code can still live with. schema:dump --prune can also replace a long history with one SQL file.

Key takeaways

  • php artisan migrate runs the pending files of database/migrations in file-name order as one batch, and migrate:rollback calls the down() methods of the last batch in reverse order.
  • Amounts belong in an integer column of whole paise: three float instalments of 1,500.10 added up to 4500.299999999999, while three times 150010 is exactly 450030.
  • A foreign key with cascadeOnDelete() deleted Asha’s 3 instalments with her, restrictOnDelete() on the receipts refused Ravi’s delete, and a soft delete kept every row.
  • A unique index over student_id and number refused a second instalment 2, and generated index names can pass MySQL’s 64-character limit, so long ones are named by hand.
  • change() keeps only the modifiers written in its line, so the widening that forgot nullable() stopped on the first student without a phone and, on SQLite, left __temp__students behind.
  • schema:dump --prune replaces old migrations with one SQL file, and in production --force skips the only question, so a guard, a tested backup and backwards-compatible migrations do the protecting.

Terms in this lesson

Blueprint
The object a Schema::create or Schema::table closure receives as $table. Each call on it, such as string('name', 80) or unique(['student_id', 'number']), adds a column, an index or a key to the table being built or changed.
cascade delete
A foreign key rule, cascadeOnDelete() in a migration, under which deleting a parent row also deletes every row that points at it, in the same statement.
column modifier
A call chained after a column type in a migration that changes how the column behaves, such as nullable(), default(0) or unsigned(). A change() keeps only the modifiers written in its own line.
composite index
An index over two or more columns taken together, such as index(['student_id', 'number']). Unique, it refuses a repeated combination while each column on its own may repeat.
database index
A sorted copy of one or more columns that the database keeps beside a table, so it can find matching rows without reading every row. A unique index also refuses repeated values.
database schema
The shape of a database: its tables, their columns and types, and the indexes and keys that connect them. In Laravel the migrations build it, and db:table shows one table of it.
foreign key
A column that holds the primary key of a row in another table, with a constraint that makes the database refuse a value matching no row there. foreignId('student_id')->constrained() creates one.
schema dump
A SQL file in database/schema, written by php artisan schema:dump, that holds the current tables and the list of migrations already run. On an empty database migrate loads it first, then runs only newer migrations.
unique constraint
A rule kept by the database that no two rows of a table may hold the same value, or the same combination of values, in the columns it covers. Laravel creates it as a unique index.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.