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 log tells 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 + controller

Laravel 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

MethodProducesUse for
id()BIGINT UNSIGNED AUTO_INCREMENT PKStandard primary key
uuid() / ulid()CHAR(36) / CHAR(26)Public-facing ids you do not want enumerable
string('x', 255)VARCHARNames, emails, short text
text() / longText()TEXT / LONGTEXTBody content
integer() / bigInteger()INT / BIGINTWhole numbers
decimal('x', 10, 2)DECIMALMoney — never use float
boolean()TINYINT(1)Flags
json()JSONLoose structured data
timestamp() / dateTime()TIMESTAMP / DATETIMEPoints in time
timestamps()created_at + updated_atAlmost every table
softDeletes()deleted_atRecoverable deletes
morphs('x')x_id + x_type + indexPolymorphic 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 deleteWhat 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 seed

migrate: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 files

Factories

A factory describes how to build one fake record. It is the foundation for both seeding and testing.

php artisan make:factory InvoiceFactory --model=Invoice
class 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 RoleSeeder
class 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 --seed

For 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 --force in CI/CD (php artisan migrate --force) — without it the confirmation prompt hangs the deploy.
  • Preview first. migrate --pretend prints 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 up

Get this right once and schema changes stop being the scary part of a release.