Database migrations are one of those things that often look harmless during code review.
A migration may contain only a few lines of SQL, the application tests are green, and everything seems ready to deploy.
But a small migration can still contain something like this:
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP COLUMN legacy_code');
}
Or this:
public function up(Schema $schema): void
{
$this->addSql('DELETE FROM audit_log');
}
Both are valid migrations.
They may even be intentional.
But they are also operations that deserve more attention before they reach production.
That is the problem I wanted to make easier to catch in CI.
The gap between valid and safe-looking
Doctrine Migrations does its job well: it gives us a structured way to manage database changes.
But Doctrine does not try to decide whether our SQL is risky.
And normal PHP static analysis is usually not interested in the meaning of SQL inside:
$this->addSql('...');
So a migration can be perfectly valid PHP and still contain a database change that should stop a deployment for review.
For example:
$this->addSql(
'ALTER TABLE customers ADD external_id VARCHAR(255) NOT NULL'
);
Adding a NOT NULL column without a default may be completely reasonable in one situation and problematic in another.
The important point is not to automatically declare it "unsafe".
The important point is to notice it before deployment.
That idea became Doctrine Migration Guard.
What Doctrine Migration Guard does
Doctrine Migration Guard is a small standalone CLI tool for static analysis of Doctrine migrations targeting MySQL and MariaDB.
It does not boot Symfony.
It does not connect to your database.
It does not execute your migrations.
It parses the migration source code, looks at supported addSql() calls inside up(), and classifies recognized SQL operations by risk.
Install it as a development dependency:
composer require --dev alkinbg/doctrine-migration-guard
Then analyze your migrations:
vendor/bin/doctrine-migration-guard migrations/
You can also analyze one migration:
vendor/bin/doctrine-migration-guard \
migrations/Version20260828090000.php
Or several files:
vendor/bin/doctrine-migration-guard \
migrations/A.php \
migrations/B.php
Risk levels instead of a simple yes/no
I did not want the tool to pretend that database migration safety can be reduced to a boolean.
Instead, findings have different levels:
| Level | Example |
|---|---|
INFO |
Creating a table or adding a nullable column |
WARNING |
Normal index, foreign key operation, or data change with a top-level WHERE
|
HIGH |
UPDATE without a top-level WHERE, rename, unique index, or some NOT NULL additions |
CRITICAL |
DROP TABLE, DROP COLUMN, TRUNCATE, or DELETE without a top-level WHERE
|
UNANALYZED |
The tool cannot classify the construct reliably |
The last one is especially important.
Fail closed instead of guessing
Static analysis becomes dangerous when the tool starts pretending it understands code that it actually does not understand.
Consider:
$sql = getMigrationSql();
$this->addSql($sql);
Doctrine Migration Guard cannot know what getMigrationSql() returns without executing application code.
So it does not guess.
The migration becomes INCOMPLETE.
The same principle applies to unsupported control flow and ambiguous SQL.
For example, a multi-action statement like this is intentionally not partially classified:
ALTER TABLE users
ADD last_login_at DATETIME DEFAULT NULL,
ADD last_seen_at DATETIME DEFAULT NULL
It would be easy to inspect the first action and produce a result.
It would also be misleading.
For the first release, I prefer a conservative answer:
I cannot analyze this reliably.
That is much more useful in CI than a false green result.
Doctrine lifecycle hooks need the same treatment
Doctrine migrations can also contain lifecycle methods such as:
public function preUp(Schema $schema): void
{
$this->addSql('DROP TABLE old_users');
}
Version 0.1 does not analyze preUp() or postUp().
If one of these hooks is overridden, the migration becomes INCOMPLETE.
Supported SQL inside up() is still analyzed and reported, but the complete migration cannot be considered fully analyzed.
Again, the goal is not to pretend that unsupported code does not exist.
Useful exit codes for CI
The CLI uses deterministic exit codes:
0 = PASSED
1 = FAILED
2 = INCOMPLETE
FAILED means at least one HIGH or CRITICAL finding was detected.
INCOMPLETE has higher priority because some part of the input could not be analyzed reliably.
That makes it easy to add the tool to CI.
For example, to analyze migrations changed on a branch:
git diff --name-only origin/main...HEAD -- 'migrations/*.php' \
| xargs -r vendor/bin/doctrine-migration-guard
Or, if the number of migrations is reasonable, simply scan the entire directory:
vendor/bin/doctrine-migration-guard migrations/
There is also JSON output for automation:
vendor/bin/doctrine-migration-guard \
--format=json \
migrations/
The JSON format currently uses schema version 1.
What it deliberately does not do
This part matters.
Doctrine Migration Guard does not know your table size, production workload, MySQL configuration, MariaDB version, execution plan, lock duration, or deployment strategy.
It also does not inspect a live database.
That means:
PASSED
does not mean:
This migration is guaranteed to be safe in production.
It means that the migration was fully analyzed under the supported static rules and no blocking finding was detected.
That is a much narrower promise, but it is one I am comfortable making.
Why I started small
It would be easy to add database connections, configuration files, suppression systems, custom plugins, Git integration, automatic fixes, PostgreSQL support, and many other features.
I intentionally did not start there.
For the first version I wanted something that is:
small, deterministic, CI-friendly, easy to understand, and conservative when it is uncertain.
The tool currently supports PHP 8.1 through PHP 8.5 and has no Symfony runtime dependency.
Symfony developers can use it, but it remains a standalone CLI tool.
Try it
The first public release is now available:
GitHub:
https://github.com/alkinbg/doctrine-migration-guard
Packagist:
https://packagist.org/packages/alkinbg/doctrine-migration-guard
Install it with:
composer require --dev alkinbg/doctrine-migration-guard
This is still an early 0.x project, so feedback is very welcome — especially real Doctrine migrations where the current rules produce an unexpected result.
For me, the interesting part is not trying to make migrations magically safe.
It is adding one more useful review step before risky database changes reach production.
Top comments (0)