doctrine/migrations
Doctrine Migrations is a database schema migration library for PHP, letting you version and track schema changes with a robust CLI and API. It integrates with Doctrine DBAL and ORM to safely apply, roll back, and manage migrations across your projects.
First, be clear about why you'd add this to a Laravel app. Laravel already ships php artisan migrate, and the two systems are not interchangeable — they use different metadata tables, different migration APIs, and different schema abstractions. Reasonable motives:
laravel-doctrine/orm) and want migrations generated from mapping metadata rather than hand-written.--dry-run SQL output, --filter-expression) for a package or shared database.If none of those apply, stay on Laravel's migrator. Running both against the same tables is the single most common way to break a deployment.
composer require doctrine/migrations:^3.9
Pin ^3.9. A 4.0 line exists (acknowledged in the 3.8.x release notes), so an unpinned requirement will eventually move you onto breaking APIs. 3.9.x also carries the recent fixes you want: DBAL 4.3 deprecation cleanup (3.9.2), Symfony 8 component support (3.9.5), schema-name handling in table metadata (3.9.7), the --nowdoc option fix (3.9.4), and DBAL 4.5 deprecation cleanup (3.9.8). If your doctrine/dbal constraint can resolve to 4.5+, make the floor ^3.9.8 rather than ^3.9 — the 3.9.8 release is purely that deprecation fix plus a CI bump, no API changes.
The library has no framework integration in it — there is no binary that magically knows about Laravel's config/database.php. You provide two things: a configuration (where migrations live, what the version table is called) and a DBAL connection (built from Laravel's connection array, translating Laravel's 'driver' => 'mysql' into DBAL's 'driver' => 'pdo_mysql' and passing host/port/dbname/user/password/charset/unix_socket).
Start with a migrations.php at the project root:
<?php
return [
'table_storage' => [
'table_name' => 'doctrine_migration_versions',
],
'migrations_paths' => [
'App\DoctrineMigrations' => __DIR__ . '/database/doctrine-migrations',
],
'all_or_nothing' => false,
'transactional' => true,
'check_database_platform' => true,
'types' => [], // custom DBAL type class => name, if you use them
];
Then bootstrap a console entry point once, and reuse it everywhere:
// app/Console/DoctrineMigrations.php
use Doctrine\DBAL\DriverManager;
use Doctrine\Migrations\Configuration\Configuration;
use Doctrine\Migrations\Configuration\Connection\ExistingConnection;
use Doctrine\Migrations\Configuration\Migration\PhpFile;
use Doctrine\Migrations\DependencyFactory;
$laravel = config('database.connections.mysql');
$connection = DriverManager::getConnection([
'driver' => 'pdo_mysql',
'host' => $laravel['host'],
'port' => $laravel['port'],
'dbname' => $laravel['database'],
'user' => $laravel['username'],
'password' => $laravel['password'],
'charset' => $laravel['charset'] ?? 'utf8mb4',
]);
$factory = DependencyFactory::fromConnection(
new PhpFile(base_path('migrations.php')),
new ExistingConnection($connection),
);
Wrap the Doctrine commands in an artisan command using Symfony's Application (Doctrine\Migrations\Tools\Console\Command\*, instantiated with $factory, setAutoExit(false), driven with ArrayInput + ConsoleOutput). That way php artisan doctrine:migrate gets your env, Sentry, and maintenance-mode checks for free, instead of shelling out to vendor/bin/doctrine-migrations and losing them.
Your first practical use case: migrations:status to confirm connection and metadata table, then migrations:generate to scaffold a class and migrations:migrate --dry-run to read the SQL before it touches anything.
DDL through the $schema argument, data through addSql(). up(Schema $schema) receives the live schema; mutate it and Doctrine diffs it into vendor-appropriate DDL for you.
public function up(Schema $schema): void
{
$table = $schema->createTable('invoices');
$table->addColumn('id', 'bigint', ['autoincrement' => true]);
$table->addColumn('total_cents', 'integer');
$table->addColumn('status', 'string', ['length' => 32, 'default' => 'draft']);
$table->setPrimaryKey(['id']);
$table->addIndex(['status'], 'idx_invoices_status');
}
public function down(Schema $schema): void
{
$schema->dropTable('invoices');
}
Split DDL and data into separate migration classes. The DDL derived from your $schema mutations is appended to the SQL queue after statements you queued in up(), so a backfill in the same class can hit a table that doesn't exist yet. One migration creates, the next populates.
Guard conditional DDL with abortIf() / skipIf(). check_database_platform catches vendor mismatches broadly; for the specific case — "this index only exists on Postgres" — use $this->platform inside abortIf() rather than branching into two versions of the file.
Write down() or declare it impossible. throwIrreversibleMigrationException() is better than a silently wrong rollback. A destructive data migration that cannot be reversed should say so loudly, because production rollback will call it.
Deploy order in CI/CD: migrations:migrate --no-interaction --all-or-nothing as a pre-release step, gated by migrations:up-to-date (non-zero exit if there are unexecuted migrations or a leftover schema diff). That gate catches the classic "someone edited the entity and forgot to generate the migration" drift.
Local loop: edit mapping → migrations:diff --formatted → review the generated file (never commit it unread) → migrations:migrate --dry-run → migrations:migrate → migrations:migrate prev to prove down() works. Add that last step to CI against a scratch database; it's the only cheap test that down() is real.
Multi-connection apps: build one DependencyFactory per DBAL connection and name the artisan commands accordingly (doctrine:migrate:reporting). Don't try to multiplex a single factory.
migrations:diff will happily drop every table Doctrine doesn't know about. In a Laravel database full of jobs, failed_jobs, sessions, cache, Horizon and Telescope tables, the ORM schema provider sees them as unmanaged and generates DROP TABLE. This is the number one way people lose data. Always pass --filter-expression to scope the diff to your ORM tables, or point Doctrine at a dedicated schema/database. Review the generated file every single time.
--dry-run only intercepts addSql(). Anything you execute directly on $this->connection->executeStatement() runs for real during a dry run. Keep dry-run-friendly migrations to addSql(), or accept that your "preview" mutated the database.
addSql() in postUp() / postDown() throws (changed in 3.8.0). Post hooks are for verification and logging, not more statements. If you were relying on that, restructure into the next migration.
Table prefixes don't exist here. Laravel's 'prefix' is applied by its query builder; DBAL knows nothing about it, so Doctrine migrations emit unprefixed names. If a connection uses a prefix, do not let both systems own the same tables.
Custom DBAL types must be registered in migrations.php under 'types'. Unregistered UUID/enum/money types make diff produce wildly wrong DDL (usually VARCHAR(255) or a fresh type change on every run).
MySQL has no transactional DDL. 'transactional' => true and 'all_or_nothing' => true give you much weaker guarantees than they do on Postgres — a failed up() can leave half the schema applied. Keep migrations small and single-purpose so a partial failure is diagnosable, and test the failure path. (--all-or-nothing also had a default-option bug fixed in 3.8.1 — don't trust older 3.8.x behavior.)
Never hand-edit the version table. 3.x uses doctrine_migration_versions and its own version format, with detection for legacy 2.x tables (isAlreadyV3Format, patched in 3.8.3). Run migrations:sync-metadata-storage on a fresh database and let the library manage the rest. Manually inserting rows to "skip" a migration usually produces a hash/format mismatch later.
ConsoleLogger was removed in 3.9.0. If you had a 'logger' service wired to it, move to a PSR-3 logger.
DBAL upgrades are the usual source of deprecation noise. 3.9.2 cleared the DBAL 4.3 batch and 3.9.8 cleared the DBAL 4.5 batch, so running DBAL 4.5 on anything below 3.9.8 will fill your deprecation log from inside the migrator. When you bump doctrine/dbal, bump doctrine/migrations to the newest 3.9.x patch in the same commit — the library's patch releases are largely DBAL-compatibility follow-ups, and there's no downside to staying current within the minor.
diff bugs worth knowing: schema-name stripping before regex filtering was fixed in 3.9.3, and schema names in table metadata in 3.9.7. If you use non-default schemas, stay on ≥ 3.9.7.
Extension points, in rough order of usefulness: custom_template in config (make generated classes match your house style — declare(strict_types=1), final, no @return noise); a custom SchemaProvider if you want diff to compare against something other than ORM metadata; getDescription() (it shows in status, so write real sentences); and the migration events (onMigrationsMigrated, per-version executed/skipped) for audit logging.
Testing: for :memory: SQLite suites, drive the Migrator and MigrationPlanCalculator programmatically from DependencyFactory in your test bootstrapper rather than invoking the console commands. It's faster and gives you the exception instead of an exit code.
How can I help you explore Laravel packages today?