Laravel Migrations: Best Practices for Teams

Laravel migrations are easy to write and easy to get wrong once more than one person writes them. The practices that keep a team's migration history healthy are few: one intention per migration, foreign keys declared with constrained(), applied migrations treated as immutable, the history squashed with schema:dump when it gets long, production runs made non-interactive and isolated, and migrations exercised by the test suite on the same engine as production.

The schema builder essentials#

A migration is a class with up() and down(), created with php artisan make:migration and named with a timestamp so that files sort in creation order. Since the anonymous-class style became the default, a file looks like this:

<?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('pets', function (Blueprint $table) {
            $table->id();
            $table->string('name', 50);
            $table->date('birth_date')->nullable();
            $table->foreignId('type_id')->constrained();
            $table->foreignId('owner_id')->constrained()->cascadeOnDelete();
            $table->timestamps();

            $table->index(['owner_id', 'name']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('pets');
    }
};

A handful of builder methods carry most of the weight:

  • $table->id() creates an unsigned big integer auto-increment primary key named id. Use it unless you have a reason to hand-pick keys.
  • $table->foreignId('owner_id')->constrained() creates the column and the foreign key in one go, inferring the referenced table (owners) from the column name. Pass the table name when the convention does not hold: constrained('people').
  • ->cascadeOnDelete(), ->nullOnDelete() and ->restrictOnDelete() make the referential action explicit. Choose one on purpose; the default varies by engine and is a common source of surprises. The trade-offs are covered in Foreign Keys Done Right.
  • $table->timestamps() adds nullable created_at and updated_at; $table->softDeletes() adds deleted_at.
  • $table->index(), $table->unique() and $table->fullText() take an array for composite indexes. Name long composite indexes yourself so the generated name does not exceed the engine's limit.

Altering an existing table uses Schema::table() with the same blueprint, plus ->change() on a column definition to modify it. Older Laravel versions needed the doctrine/dbal package for column modification; recent ones handle it natively, but the generated SQL still differs per engine, so review it.

One intention per migration, frozen once applied#

A migration named add_birth_date_to_pets_table that also creates an index on owners and renames a column in visits is three migrations wearing one file name. It is harder to review, impossible to roll back partially, and on MySQL a failure in the third statement leaves the first two applied with no record of it.

Keep each file to one table and one purpose. The file name should tell the reviewer what happened without opening it. When a feature needs several changes, several files in one pull request are fine: they still run in one migrate batch and roll back together with migrate:rollback.

Never edit a migration that has run#

Laravel records each applied file name in the migrations table together with a batch number. It does not store a checksum. That means an edited migration is silently ignored everywhere it already ran, and applied fresh everywhere it has not, which produces two different schemas from one repository. The rule is simple: once a migration has run anywhere outside your own machine, it is frozen. Fix mistakes with a new migration.

The one exception is a migration that only exists on your branch and has never been merged. Roll it back locally with migrate:rollback --step=1, edit, run again.

Add a check to code review: a diff that touches an existing file under database/migrations is a red flag unless the author explains why.

Write migrations that are safe in every environment#

A migration runs on a laptop with an empty SQLite database, in CI, on staging and on a production table with millions of rows. Habits that keep it safe everywhere:

  • No data in schema migrations. Reference data belongs in seeders (see below). If a data backfill must ship with a schema change, put it in its own migration, chunk it with DB::table()->orderBy('id')->chunkById(), and make it idempotent.
  • No environment checks inside up(). if (app()->environment('production')) inside a migration guarantees that production runs something nobody else has run.
  • Prefer additive changes. Add the new column, deploy code that writes both, backfill, switch reads, drop the old column in a later release. The expand and contract pattern describes the sequence.
  • Know your engine's locking. On MySQL, adding an index to a large table may lock it; on PostgreSQL, CREATE INDEX CONCURRENTLY cannot run inside a transaction. Laravel does not hide this. When you need it, drop to DB::statement() and document why.
  • Write a real down(), or a deliberate no-op with a comment. A down() that throws is a trap for the next person who runs migrate:rollback in an emergency.

Keep seeders separate#

Seeders (php artisan db:seed, classes under database/seeders) exist so that data does not creep into migrations. Use them for development fixtures and for reference tables such as pet types or countries, and make them idempotent with updateOrInsert() or upsert() so they can be run again after a deploy. Factories belong to tests. A migration that inserts rows is almost always a seeder in the wrong place.

Squash the history with schema:dump#

After a couple of years a project has hundreds of migration files. Fresh installs and CI replay all of them, which is slow and occasionally breaks when an old migration depends on a package that has since changed. Laravel's answer is a schema dump:

php artisan schema:dump
php artisan schema:dump --prune

The first command writes the current schema of the configured connection to database/schema/mysql-schema.sql (or the equivalent for PostgreSQL or SQLite) using the engine's own dump tool. The second also deletes the existing migration files. From then on, migrate on an empty database loads the dump first and then runs only the migrations created afterwards. Databases that already have the old migrations recorded are unaffected.

Two cautions. The dump is engine-specific, so a team that develops on SQLite and deploys to MySQL needs to decide which dump to keep, and ideally stop developing on a different engine (the reasons are in Using SQLite in Development When Production Is PostgreSQL). And a dump replaces history, not documentation: keep the pull requests that explain why columns exist.

Running migrations in production#

In a non-interactive deployment Laravel refuses to migrate a production database unless told explicitly:

php artisan migrate --force --isolated
php artisan migrate:status

--force acknowledges the environment. --isolated takes a cache lock so that two servers deploying at the same moment do not both run the migrations; it needs a cache driver that supports atomic locks. Run migrations as a separate deployment step before the new application version receives traffic, not lazily from a request. migrate --pretend prints the SQL without running it, which is the cheapest possible review of what a release will do to the database.

Roll forward rather than back. migrate:rollback is useful during development, but in production a failed release is better fixed with a new migration than with a down() that may destroy data.

Test the migrations, not just the models#

The RefreshDatabase trait runs the migrations once per test run and wraps each test in a transaction, so the migrations are exercised every time the suite runs. That is only meaningful if the test database is the same engine and a similar version as production. An in-memory SQLite database is fast and convenient, but it accepts things MySQL rejects, ignores some constraints, and has a different ALTER TABLE, so a migration that passes there can fail on deploy. Run the suite against a real MySQL or PostgreSQL in CI, in a container, even if developers keep SQLite locally.

Two more checks are cheap and catch real problems:

  • Run migrate:fresh followed by migrate:rollback and migrate again in CI. It proves every down() works and every migration is repeatable.
  • After migrating, compare the resulting schema with what you expect: a diagram or a schema dump in the pull request makes a missing foreign key visible before it reaches production.

Where SchemaDrawer fits#

A migration history describes the schema indirectly; a diagram shows it. SchemaDrawer takes the files under database/migrations, really runs them with Laravel's migrator in Docker against a throw-away MySQL, MariaDB, PostgreSQL or SQLite of the version you pick, and returns an SVG with every table, column, primary key and foreign key the migrations produced. Because it executes the real migrations rather than reading them, a foreignId() that was never constrained() shows up as a column with no arrow, which is exactly the kind of omission code review misses. The Laravel page describes the accepted layout, and the GitHub Action can attach the diagram to every pull request that touches a migration.

Key takeaways#

  • One table and one purpose per migration; the file name should be enough to review it.
  • Applied migrations are frozen. Laravel stores names, not checksums, so an edited migration diverges silently.
  • Use constrained() and an explicit onDelete action for every foreign key.
  • Squash long histories with schema:dump --prune, and keep the dump on your production engine.
  • Deploy with migrate --force --isolated as a separate step, review with --pretend, and roll forward.
  • Test against the production engine in CI, and keep data in seeders.
Marek Hudyma
Builds SchemaDrawer, a service that draws database diagrams by running your migrations on a real engine. About
Keep reading

Related articles

Draw your schema in the next five minutes

One HTTP call with your migration files, one poll, one SVG. Free while we build it.