Why Migrations Beat a SQL Dump
A migration is a PHP class describing one schema change, committed alongside the code that needs it. That gives you four things a dumped .sql file cannot:
- History.
git logtells you when a column appeared and which pull request added it. - Repeatability. Every environment applies the same changes in the same order.
- Reversibility. A bad deploy rolls back the schema alongside the code.
- Portability. The same migration produces valid MySQL, PostgreSQL, SQLite and SQL Server.
Laravel tracks applied migrations in a migrations table, so running migrate twice is a no-op rather than a disaster.
Creating a Migration
php artisan make:migration create_invoices_table
php artisan make:migration add_status_to_invoices_table --table=invoices
php artisan make:model Invoice -mfsc # model + migration + factory + seeder + controllerLaravel infers intent from the name: create_x_table scaffolds a create block, add_y_to_x_table scaffolds a table modification.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('number')->unique();
$table->decimal('total', 10, 2);
$table->string('currency', 3)->default('USD');
$table->enum('status', ['draft', 'sent', 'paid', 'void'])->default('draft');
$table->timestamp('paid_at')->nullable();
$table->json('line_items')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['user_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('invoices');
}
};The filename timestamp determines execution order. Two developers creating migrations on separate branches on the same day will normally merge fine, but check the order after a rebase if one depends on the other.
Column Types Worth Knowing
| Method | Produces | Use for |
|---|---|---|
id() | BIGINT UNSIGNED AUTO_INCREMENT PK | Standard primary key |
uuid() / ulid() | CHAR(36) / CHAR(26) | Public-facing ids you do not want enumerable |
string('x', 255) | VARCHAR | Names, emails, short text |
text() / longText() | TEXT / LONGTEXT | Body content |
integer() / bigInteger() | INT / BIGINT | Whole numbers |
decimal('x', 10, 2) | DECIMAL | Money — never use float |
boolean() | TINYINT(1) | Flags |
json() | JSON | Loose structured data |
timestamp() / dateTime() | TIMESTAMP / DATETIME | Points in time |
timestamps() | created_at + updated_at | Almost every table |
softDeletes() | deleted_at | Recoverable deletes |
morphs('x') | x_id + x_type + index | Polymorphic relations |
Never store money in a float. 0.1 + 0.2 is not 0.3 in binary floating point, and those fractions of a cent compound across a ledger. Use decimal(10, 2), or store integer minor units (cents) — which is what Stripe does.
Modifiers, Indexes and Keys
$table->string('email')->unique();
$table->string('nickname')->nullable();
$table->integer('sort_order')->default(0);
$table->string('slug')->index();
$table->text('notes')->nullable()->comment('Internal only');
$table->timestamp('created_at')->useCurrent();
$table->string('code')->after('name'); // MySQL only
// Composite index — order matters, most selective column first
$table->index(['tenant_id', 'status', 'created_at']);
// Composite unique constraint
$table->unique(['tenant_id', 'slug']);
// Full-text search (MySQL / PostgreSQL)
$table->fullText(['title', 'body']);
// Named explicitly, so you can drop it later without guessing
$table->index('user_id', 'invoices_user_idx');Index the columns you filter, join and sort on — foreign keys especially. But every index slows writes and consumes disk, so do not index everything “just in case”. Run EXPLAIN on your slow queries and add indexes the plan actually asks for.
Foreign Keys
// The modern shorthand — creates the column AND the constraint
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// Different table or key name
$table->foreignId('author_id')->constrained('users')->cascadeOnDelete();
// Nullable relation — null the column instead of deleting the row
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
// Block deletion of the parent while children exist
$table->foreignId('order_id')->constrained()->restrictOnDelete();
// UUID keys
$table->foreignUuid('tenant_id')->constrained();
// The long form, if you need full control
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')
->references('id')->on('users')
->onDelete('cascade')
->onUpdate('cascade');| On delete | What happens to the child row |
|---|---|
cascadeOnDelete() | Deleted with the parent |
nullOnDelete() | Foreign key set to NULL |
restrictOnDelete() | Parent deletion is blocked |
noActionOnDelete() | Left to the database default |
SQLite ignores foreign keys unless they are explicitly enabled. If your tests run on SQLite and production runs on MySQL, constraint violations will not surface until deploy. Either enable them in the test config or run tests against the same engine as production.
Modifying Existing Tables
Schema::table('invoices', function (Blueprint $table) {
// Add
$table->string('po_number')->nullable()->after('number');
// Change type or modifiers — requires doctrine/dbal before Laravel 11
$table->string('number', 64)->change();
// Rename
$table->renameColumn('total', 'amount_total');
// Drop
$table->dropColumn('legacy_ref');
$table->dropColumn(['old_a', 'old_b']);
// Indexes and constraints
$table->dropIndex(['user_id', 'status']);
$table->dropUnique('invoices_number_unique');
$table->dropForeign(['user_id']);
});->change() rewrites the entire column definition from what you pass in Laravel 11+. If the original was ->nullable()->default('x') and you only write ->string('number', 64)->change(), you have just dropped the nullability and the default. Always restate every modifier you want to keep.
Write a real down(). A migration you cannot reverse is a migration you cannot safely deploy:
public function up(): void
{
Schema::table('invoices', fn (Blueprint $t) => $t->string('po_number')->nullable());
}
public function down(): void
{
Schema::table('invoices', fn (Blueprint $t) => $t->dropColumn('po_number'));
}Running and Rolling Back
php artisan migrate # apply pending migrations
php artisan migrate --pretend # print the SQL, change nothing
php artisan migrate:status # what has and has not run
php artisan migrate:rollback # undo the last batch
php artisan migrate:rollback --step=1 # undo one migration
php artisan migrate:reset # undo everything
php artisan migrate:refresh # reset then re-run
php artisan migrate:fresh # DROP all tables then re-run
php artisan migrate:fresh --seed # ...and seedmigrate:fresh drops every table in the database. It is the right command locally and an outage in production. Laravel prompts for confirmation when APP_ENV=production — do not add --force to that one out of habit.
Migrations run in batches. rollback undoes the whole most-recent batch, which is usually what you want after a failed deploy.
Once a project accumulates hundreds of migrations, squash them:
php artisan schema:dump # snapshot current schema
php artisan schema:dump --prune # snapshot and delete the old migration filesFactories
A factory describes how to build one fake record. It is the foundation for both seeding and testing.
php artisan make:factory InvoiceFactory --model=Invoiceclass InvoiceFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'number' => 'INV-' . fake()->unique()->numerify('######'),
'total' => fake()->randomFloat(2, 20, 5000),
'currency' => fake()->randomElement(['USD', 'EUR', 'GBP']),
'status' => 'draft',
'paid_at' => null,
];
}
// States — named variations
public function paid(): static
{
return $this->state(fn (array $attrs) => [
'status' => 'paid',
'paid_at' => fake()->dateTimeBetween('-6 months'),
]);
}
public function overdue(): static
{
return $this->state(fn () => [
'status' => 'sent',
'created_at' => fake()->dateTimeBetween('-90 days', '-31 days'),
]);
}
}Invoice::factory()->create(); // one, persisted
Invoice::factory()->count(50)->create(); // fifty
Invoice::factory()->make(); // built, not saved
Invoice::factory()->paid()->count(10)->create(); // using a state
Invoice::factory()->create(['currency' => 'INR']); // override
// Relationships
User::factory()
->has(Invoice::factory()->count(5)->paid())
->create();
Invoice::factory()
->for(User::factory()->state(['name' => 'Acme Ltd']))
->create();
// Sequences — cycle values across the generated rows
Invoice::factory()
->count(9)
->sequence(
['status' => 'draft'],
['status' => 'sent'],
['status' => 'paid'],
)
->create();Use fake()->unique() on any column with a unique index, and reset it between large batches with fake()->unique(true). Duplicate-key errors halfway through a seed are almost always this.
Seeders
Seeders come in two flavours, and it helps to keep them apart:
- Reference data the application genuinely needs — countries, currencies, roles, plan tiers. These run in production too.
- Demo data for local development and staging. These never run in production.
php artisan make:seeder RoleSeederclass RoleSeeder extends Seeder
{
public function run(): void
{
// Idempotent — safe to run repeatedly
foreach (['admin', 'editor', 'viewer'] as $name) {
Role::firstOrCreate(['slug' => $name], ['name' => ucfirst($name)]);
}
}
}class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Always
$this->call([
RoleSeeder::class,
CountrySeeder::class,
]);
// Local and staging only
if (! app()->isProduction()) {
$this->call(DemoDataSeeder::class);
}
}
}php artisan db:seed
php artisan db:seed --class=RoleSeeder
php artisan migrate:fresh --seedFor bulk demo data, insert in chunks rather than one model at a time — 10,000 individual create() calls means 10,000 inserts:
class DemoDataSeeder extends Seeder
{
public function run(): void
{
User::factory()
->count(500)
->has(Invoice::factory()->count(10))
->create();
// Or raw chunked inserts for very large volumes
collect(range(1, 100_000))
->chunk(1000)
->each(fn ($chunk) => DB::table('events')->insert(
$chunk->map(fn ($i) => [
'name' => 'event_' . $i,
'created_at' => now(),
])->all()
));
}
}Production Safety
- Always
--forcein CI/CD (php artisan migrate --force) — without it the confirmation prompt hangs the deploy. - Preview first.
migrate --pretendprints the SQL so you can see what is about to run against live data. - Additive changes deploy safely; destructive ones do not. Adding a nullable column is fine mid-deploy. Dropping or renaming one breaks the old code still running during the rollout.
- Split renames into three deploys: add the new column and write to both; backfill and switch reads; drop the old column. Tedious, but it is the only zero-downtime path.
- Backfill in a job, not a migration. A migration that updates two million rows holds a lock and times out. Add the column in the migration, fill it from a queued job or a chunked command.
- Beware implicit commits. MySQL cannot roll back DDL, so a migration that fails halfway leaves the schema partly changed. Keep each migration to one logical change.
- Back up before anything destructive. Obvious, and still the step people skip.
# A reasonable deploy sequence
composer install --no-dev --optimize-autoloader
php artisan down --retry=15
php artisan migrate --force
php artisan optimize
php artisan queue:restart
php artisan upGet this right once and schema changes stop being the scary part of a release.