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.
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.
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
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
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 namedid;foreignId('student_id'), an unsigned big integer holding the key of another table (the next section);unsignedTinyInteger('number')for the instalment’s number, andunsignedInteger('amount_paise')for its amount;date('due_on'), anddate('paid_on')->nullable()for a payment that has not happened yet;timestamps(), the nullablecreated_atandupdated_atthat 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:
<?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:
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
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:
<?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; 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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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:
<?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;
} 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.
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
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():
<?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:
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- With nullable() repeated. The corrected definition keeps phone nullable, so the new table accepts students without a phone, and all three rows are copied.
- 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:
<?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:
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
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:
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
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:
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:
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:
<?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:
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
Practise: an attendance register for Pathshala’s batches
Build the register in your own copy of the project, one migration at a time:
- Create a migration for
class_sessionswithid(),string('batch', 40),date('held_on')andtimestamps(). Run it and look for its batch number inphp artisan migrate:status. - Create
attendance_markswithforeignId('class_session_id'),foreignId('student_id'), astatusthat holds present, absent or late, andtimestamps(). Choose each key’s delete rule with the trade-off table: should a mark outlive its session, and its student? - Add
unique(['class_session_id', 'student_id'])with a name of your own, runphp artisan db:table attendance_marks, and find the index and both foreign keys in what it prints. - Write a script like
duplicates.phpthat marks one student twice for one session, and check that the second insert fails withUNIQUE constraint failed. - Roll back with
php artisan migrate:rollback, check that the file showsPending, and migrate again. - Add a nullable
notecolumn in a new migration, widen it withchange(), and confirm indb:tablethat 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.
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.
-
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?
-
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?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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 migrateruns the pending files ofdatabase/migrationsin file-name order as one batch, andmigrate:rollbackcalls thedown()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_idandnumberrefused 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 forgotnullable()stopped on the first student without a phone and, on SQLite, left__temp__studentsbehind.schema:dump --prunereplaces old migrations with one SQL file, and in production--forceskips 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
- Database: Migrations (Laravel (laravel.com))
- Database: Getting Started (Laravel (laravel.com))
- laravel/framework 13.x: the DB facade (prohibitDestructiveCommands) (Laravel (github.com/laravel))
- laravel/framework 13.x: the SQLite schema grammar (Laravel (github.com/laravel))
- laravel/framework 13.x: the schema grammar base class (migrations in transactions) (Laravel (github.com/laravel))
- Floating point numbers (The PHP Group (php.net))
- Datatypes In SQLite (SQLite (sqlite.org))
- SQLite Foreign Key Support (SQLite (sqlite.org))
- ALTER TABLE (SQLite (sqlite.org))
- Identifier Length Limits (Oracle (dev.mysql.com))
- Fixed-Point Types (Exact Value): DECIMAL, NUMERIC (Oracle (dev.mysql.com))
- Numeric Types (The PostgreSQL Global Development Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress