The Schema Builder
Blueprint declares columns, modifiers and indexes, and compiles them to DDL for whichever database you are connected to.
$table->foreignId('user_id')->constrained()->cascadeOnDelete();Inside Schema::create Laravel hands your closure a Blueprint. Each method on it declares one column or one index, and everything chained after it is a modifier. The Schema facade passes the finished Blueprint to the schema grammar for the current connection, which is why the same file compiles to MySQL, PostgreSQL or SQLite DDL.
Choose the type deliberately
$table->id() is shorthand for $table->bigIncrements('id'): an unsigned 8-byte auto-incrementing primary key. string($column, $length = null) falls back to Illuminate\Database\Schema\Builder::$defaultStringLength, which is 255. 255 is a default, not a decision. Declare the length you mean, especially for a column you will index. Use text() when there is no sensible limit, remembering that MySQL will not accept a default on a text column. Money belongs in decimal('total', 12, 2) and never in a float.
Keys and indexes
foreignId('user_id')->constrained() is two declarations in one. The longhand is:
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')->references('id')->on('users');cascadeOnDelete() is the same thing as onDelete('cascade'), and nullOnDelete() is onDelete('set null'). The modifier ->unique() creates the same index as $table->unique('email'); only the array form, $table->unique(['team_id', 'number']), builds a composite index.
Example
<?php
// database/migrations/2026_04_02_091200_create_invoices_table.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('invoices', function (Blueprint $table) {
$table->id();
// Required owner: delete the team, delete its invoices.
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
// Optional, and the column name does not match the table,
// so the table is named explicitly.
$table->foreignId('approved_by')
->nullable()
->constrained('users')
->nullOnDelete();
$table->string('number', 24);
$table->string('status', 20)->default('draft')->index();
$table->decimal('total', 12, 2);
$table->char('currency', 3)->default('EUR');
$table->text('notes')->nullable();
$table->timestamp('issued_at')->nullable();
$table->timestamps();
// Invoice numbers are unique per team, not globally.
$table->unique(['team_id', 'number']);
});
}
public function down(): void
{
Schema::dropIfExists('invoices');
}
};When to use it
- Create a table whose column types match the real data instead of string(255) everywhere.
- Add a foreign key that deletes child rows with their parent.
- Enforce uniqueness per tenant with a composite unique index on team_id and slug.
- Widen a varchar without silently dropping its NOT NULL constraint.
More examples
Changing a column, and what change() forgets
Since Laravel 11, change() redefines the column rather than patching it. Any modifier you leave out is dropped: omit nullable(false) and the column becomes nullable, omit default() and the default disappears. Read the current definition first and restate all of it. Note also that indexes get a generated name, so dropIndex needs that name or the array form dropIndex(['issued_at']).
<?php
// database/migrations/2026_04_09_140000_widen_invoice_number.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('invoices', function (Blueprint $table) {
// Every modifier the column should keep must be restated.
$table->string('number', 40)->nullable(false)->change();
$table->index('issued_at');
});
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->string('number', 24)->nullable(false)->change();
// Laravel's generated name: table_column(s)_index.
$table->dropIndex('invoices_issued_at_index');
});
}
};Shorthand and longhand, side by side
Useful in both directions. You will meet the long form in older migrations and in code generated by database tools, and you need to see that it is the same schema. It also makes two things visible that the shorthand hides: string('email') really is varchar(255), and timestamps() creates two nullable columns named created_at and updated_at, which are the names Eloquent looks for.
<?php
// Shorthand
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('email')->unique();
$table->timestamps();
// The same four lines spelled out
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('cascade');
$table->string('email', 255);
$table->unique('email');
$table->timestamp('created_at')->nullable();
$table->timestamp('updated_at')->nullable();Why money is decimal, not float
The argument for decimal('total', 12, 2) over float('total') in one runnable snippet. The 12 is total digits and the 2 is digits after the point, so this column holds up to ten digits of currency. Reach for integer cents instead when you need exact addition in PHP as well as in the database, since a decimal column comes back as a string and casting it to float puts the drift straight back.
<?php
// Plain PHP 8.5, no framework. A binary float cannot hold 0.1 exactly,
// so totals built by adding floats drift away from the arithmetic
// a customer would do on paper.
var_dump(0.1 + 0.2 === 0.3);
echo number_format(0.1 + 0.2, 20), "\n";
// Integer cents, or a decimal column that PDO hands back as a string,
// does not drift.
$cents = array_sum([10, 20]);
var_dump($cents === 30);
// Output:
// bool(false)
// 0.30000000000000004441
// bool(true)
Discussion