Laravel Migrations: Best Practices for Teams
Practical rules for Laravel migrations in a team: one change per file, constrained foreign keys, frozen history, schema dumps, safe production runs and tests.
Timestamped PHP migrations using the Schema builder, as php artisan make:migration creates them. SchemaDrawer runs Laravel's illuminate/database 13 on PHP 8.4 against a throw-away database of the engine you choose and returns an SVG of the schema it produces.
Upload the migrations directory: files named <timestamp>_<title>.php that return an anonymous class extending Migration, as php artisan make:migration writes them. The older named-class form works as well. Subdirectories are scanned and all files are sorted by name.
The sample below is the PetClinic schema written for Laravel, one of the sets SchemaDrawer's own tests draw on every engine:
database/migrations/ ├── 2024_01_01_000001_create_owners_and_pets.php ├── 2024_01_02_000002_create_vets.php └── 2024_01_03_000003_create_visits.php
PHP files using the Schema builder and Blueprint.
Illuminate's Migrator, driven by a small script instead of php artisan migrate. The database is a fresh PostgreSQL, MySQL, MariaDB, SQL Server, Oracle or SQLite container in the version you ask for; nothing of yours is touched.
PostgreSQL 10, 11, 12, 13, 14, 15, 16, 17, 18 MySQL 8.0, 8.4, 9 MariaDB 10.6, 11.4, 11.8, 12 SQL Server 2017, 2019, 2022, 2025 Oracle SQLite
Not offered for Laravel: Oracle. The tool ships no driver for it, so nothing could run.
migrationType=LARAVELOwners, pets, types, vets, specialties and visits: the same seven tables every sample set on this site creates. These are the exact files the tests upload.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('owners', function (Blueprint $table) {
$table->integer('id');
$table->string('first_name', 50);
$table->string('last_name', 50);
$table->string('address', 255)->nullable();
$table->string('city', 80)->nullable();
$table->string('telephone', 20)->nullable();
$table->primary('id');
});
Schema::create('types', function (Blueprint $table) {
$table->integer('id');
$table->string('name', 80);
$table->primary('id');
});
Schema::create('pets', function (Blueprint $table) {
$table->integer('id');
$table->string('name', 50);
$table->date('birth_date')->nullable();
$table->integer('type_id');
$table->integer('owner_id');
$table->primary('id');
$table->foreign('type_id')->references('id')->on('types');
$table->foreign('owner_id')->references('id')->on('owners');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('pets');
Schema::dropIfExists('types');
Schema::dropIfExists('owners');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('vets', function (Blueprint $table) {
$table->integer('id');
$table->string('first_name', 50);
$table->string('last_name', 50);
$table->primary('id');
});
Schema::create('specialties', function (Blueprint $table) {
$table->integer('id');
$table->string('name', 80);
$table->primary('id');
});
Schema::create('vet_specialties', function (Blueprint $table) {
$table->integer('vet_id');
$table->integer('specialty_id');
$table->primary(['vet_id', 'specialty_id']);
$table->foreign('vet_id')->references('id')->on('vets');
$table->foreign('specialty_id')->references('id')->on('specialties');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('vet_specialties');
Schema::dropIfExists('specialties');
Schema::dropIfExists('vets');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('visits', function (Blueprint $table) {
$table->integer('id');
$table->integer('pet_id');
$table->date('visit_date')->nullable();
$table->string('description', 255)->nullable();
$table->primary('id');
$table->foreign('pet_id')->references('id')->on('pets');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('visits');
}
};
Every variant of the sample produces these seven tables. This diagram was drawn from the Flyway variant on PostgreSQL 18; on MySQL the types read INT and VARCHAR, on Oracle the names are upper case.
One multipart request queues the drawing; poll the job until it succeeds and download the SVG. Or let the GitHub Action do it and open a pull request with the diagram.
curl -X POST https://schemadrawer.com/api/v1/drawing \
-H "Request-Id: $(uuidgen)" \
-F "userId=5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f" \
-F "migrationType=LARAVEL" \
-F "databaseType=POSTGRESQL" \
-F "version=18" \
-F "files=@database/migrations/2024_01_01_000001_create_owners_and_pets.php" \
-F "files=@database/migrations/2024_01_02_000002_create_vets.php" \
-F "files=@database/migrations/2024_01_03_000003_create_visits.php"
# {"jobId":"0b6e2f4c-8d1a-4c3e-9f7a-2a5b6c7d8e9f"}
Then poll GET https://schemadrawer.com/api/v1/drawing/{jobId} until status is SUCCESS and fetch diagramUrl. Details in the getting started guide and the API reference.
- uses: schemadrawercom/action@v1
with:
sql-directory: database/migrations
output-path: docs/schema-diagram.svg
migration-type: LARAVEL
database-type: POSTGRESQL
database-version: '18'
github-token: ${{ secrets.GITHUB_TOKEN }}
The action uploads the directory, waits for the diagram and opens a pull request with the SVG. See the action reference.
DB and Schema facades and Eloquent. A migration that needs your application's container, config or models cannot run.migrations table is left out of the diagram, so a table of your own with that name would disappear too.Practical rules for Laravel migrations in a team: one change per file, constrained foreign keys, frozen history, schema dumps, safe production runs and tests.
Editing applied migrations, environment-specific scripts, ORM auto-DDL in production, untested rollbacks: ten habits that break deployments, with a fix for each.
How Diesel manages a schema in Rust: the CLI, timestamped folders with up.sql and down.sql, the versions table, the generated schema.rs and embedded migrations.
Upload the migration directory, get an SVG back. Free while we build it.