furybee / laravel-migration-radar
Catch dangerous database migrations before they reach production, aware of your table sizes and database version.
Package info
github.com/furybee/laravel-migration-radar
pkg:composer/furybee/laravel-migration-radar
Requires
- php: >=8.2
- composer-runtime-api: ^2.0
- ext-mbstring: *
- nikic/php-parser: ^5.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.5
Suggests
- ext-pdo: Read table sizes and the server version from the database (--dsn)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-19 17:56:23 UTC
README
Laravel Migration Radar
Catch dangerous database migrations before they reach production — aware of your table sizes and database version.
A migration that runs in a second on your laptop can lock a 50-million-row table for twenty minutes in production, or break the code still running while the release rolls out. Migration Radar reads your Laravel migrations without running them and reports those operations, with the safe way to do the same thing.
What makes it different from a list of "never do X" rules:
- It knows your database. Adding a column is instant on MySQL 8.0.29 but rebuilds the table on 5.7; an index blocks writes on PostgreSQL but not on InnoDB. Rules follow the engine and version you target.
- It knows your table sizes. Lock and rebuild rules only fire on tables large enough to matter, read from the database statistics or from your configuration. No noise on a 200-row settings table.
- It fits in CI. Analyse only the migrations of the branch (
--since=origin/main), get GitHub annotations or a GitLab Code Quality report, fail on errors only or on warnings too.
database/migrations/2026_01_01_000000_add_foreign_key_to_users.php
11 ERROR add-foreign-key-locks-table
Adding foreign key on (team_id) to "users" (~5,000,000 rows) copies the whole table while blocking writes (foreign_key_checks forces ALGORITHM=COPY).
→ Run it with an online schema change tool (gh-ost, pt-online-schema-change), or declare the foreign key when creating the table.
database/migrations/2026_01_02_000000_drop_users_legacy_column.php
11 WARNING drop-column
Dropping column "users.legacy": the code currently deployed fails if it still reads or writes it.
→ Remove every use of the column first (queries, model attributes, casts, fillable) and deploy it, then drop the column in a later release.
3 migration(s) analysed on MySQL 8.0.35: 1 error(s), 1 warning(s), 0 info, 1 ignored
Installation
composer require --dev furybee/laravel-migration-radar
Requires PHP 8.2+. Supports MySQL, MariaDB and PostgreSQL migrations written with Laravel's schema builder.
Usage
# Every migration of database/migrations vendor/bin/migration-radar # Only the migrations added or changed on the current branch: the ones that have not run in production yet vendor/bin/migration-radar --since=origin/main # Tell it the target database... vendor/bin/migration-radar --platform=mysql:8.0.35 # ...or let it read the version and the table sizes (no table is scanned, only engine statistics) MIGRATION_RADAR_DSN=mysql://readonly:secret@replica:3306/app vendor/bin/migration-radar # List the rules vendor/bin/migration-radar rules
| Option | Default | |
|---|---|---|
paths (arguments) |
database/migrations |
Migration files or directories |
--since=REF |
Only migrations added or modified since this git reference, committed or not. Tables they create are new for the migrations after them | |
--platform=P |
mysql:8.0.35, mariadb:10.11, pgsql:16... wins over what the database reports |
|
--dsn=URL |
MIGRATION_RADAR_DSN |
mysql://, pgsql:// or sqlite:// URL to read the version and table sizes |
--format=F |
text |
text, json, github (annotations), gitlab (Code Quality report) |
--fail-on=S |
error |
Lowest severity failing the command: error, warning, info, never |
--config=FILE |
migration-radar.php |
Configuration file |
Exit codes: 0 no issue at or above --fail-on, 1 issues found, 2 invalid input (configuration, platform,
unreachable database, unknown git reference).
Without database access
Give the platform with its full version and, optionally, the size of your big tables. Without any of this,
Migration Radar presumes every table large and only reports the risks shared by all engines (with an unknown engine,
engine specific errors become warnings): it reports more than needed rather than missing an outage, and tells you
so in a hint. A table missing from the database statistics stays presumed large: set table_prefix if Laravel
prefixes your tables, or the statistics will not match.
// migration-radar.php return [ 'platform' => 'mysql:8.0.35', 'table_rows' => ['users' => 4_000_000, 'audit_logs' => 120_000_000], ];
mysql:8.0 without the patch number is read as 8.0.0: write the full version to get instant ADD COLUMN recognised.
Rules
| Rule | Severity | Reports |
|---|---|---|
not-null-column-without-default |
error | A NOT NULL column without default added to an existing table: inserts from the code still deployed fail, PostgreSQL rejects it on a non-empty table |
add-column-rewrites-table |
error / warning | A column whose addition rebuilds a large table: MySQL < 8.0.12 (8.0.29 with after()), MariaDB < 10.3.2 (10.4 with after()), PostgreSQL < 11 with a default, volatile expression defaults, stored generated and auto-increment columns |
change-column |
error | ->change() on a large table: copies (MySQL) or rewrites (PostgreSQL) it for most changes |
rename-column |
error | Renaming a column the deployed code still uses |
rename-table |
error | Renaming a table the deployed code still uses |
drop-column |
warning | Dropping a column the deployed code may still use |
drop-table |
warning | Dropping a table, which cannot be undone |
add-index-locks-table |
error / warning | An index blocking writes on a large table: any index on PostgreSQL (no CONCURRENTLY), full text and spatial indexes on InnoDB; a primary key rebuilds InnoDB tables |
add-foreign-key-locks-table |
error | A foreign key on a large table: InnoDB copies the table, PostgreSQL locks both tables while validating |
mixed-schema-and-data |
warning | Schema and data changed in the same migration: not transactional on MySQL, and backfills hold the locks |
raw-sql |
info | DB::statement() / DB::unprepared(), which cannot be analysed |
On an unknown platform, errors that depend on the engine are reported as warnings. Operations on a table created by
the same migration, or by an earlier migration of the branch with --since, are never reported: it is empty.
Every issue comes with a recommendation: expand/contract steps, CREATE INDEX CONCURRENTLY, NOT VALID constraints,
online schema change tools (gh-ost, pt-online-schema-change).
Ignoring an issue
Above the statement, with the rules and a reason:
// migration-radar-ignore drop-column -- unused since the v2 API, removed in #1234 Schema::table('users', function (Blueprint $table) { $table->dropColumn('legacy_token'); });
On its own line, the comment covers the next statement, closure included. At the end of a line, it only covers that
line. Without rule ids it ignores every rule. In the configuration, ignore maps path globs to rule ids (* for all),
and disabled_rules turns rules off.
Configuration
migration-radar.php at the root of the project, every key optional. See examples/migration-radar.php.
| Key | Default | |
|---|---|---|
paths |
['database/migrations'] |
Where migrations live |
platform |
Target database, e.g. mysql:8.0.35 |
|
dsn |
Database URL, better passed as MIGRATION_RADAR_DSN |
|
large_table_rows |
1_000_000 |
Rows from which a table is large |
assume_large_tables |
true |
Whether a table without statistics is presumed large |
table_rows |
[] |
Row estimates per table, wins over the database statistics |
table_prefix |
'' |
Laravel's table prefix, stripped from the names the database reports |
disabled_rules |
[] |
Rule ids turned off |
ignore |
[] |
Path glob => rule ids |
Unknown keys and invalid values are rejected with a clear message: a typo never silently changes the analysis.
In CI
GitHub Actions, with annotations on the pull request:
- run: vendor/bin/migration-radar --since=origin/${{ github.base_ref }} --format=github env: MIGRATION_RADAR_DSN: ${{ secrets.MIGRATION_RADAR_DSN }} # optional, a read-only replica
Fetch the base branch first (actions/checkout with fetch-depth: 0).
GitLab CI, with the Code Quality widget on the merge request:
migration-radar: script: - git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME - vendor/bin/migration-radar --since=origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME --format=gitlab > gl-code-quality-report.json artifacts: when: always reports: codequality: gl-code-quality-report.json rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"
How it works
Migrations are parsed, never executed. The up() method is read with nikic/php-parser:
Schema::create/table/drop/rename calls, the $table->... calls of their closures (columns and modifiers, indexes,
foreign keys, drops, renames), DB::statement() and data writes (DB::table()->update(), Eloquent update(),
chunkById()...). Table names given as literals, class constants or concatenations are resolved.
Limits:
- A table name only known at runtime shows as
(dynamic)and is presumed large (unlessassume_large_tablesis off). - Operations hidden in helper methods or traits are not followed; raw SQL is reported, not analysed.
- Blueprint macros are not known: they are ignored rather than guessed.
- Instant
ADD COLUMNon MySQL 8.0.29+ still rebuilds tables with a FULLTEXT index,ROW_FORMAT=COMPRESSED, or 64 instant changes behind them: the schema does not tell, so these cases are not reported. - Requires nikic/php-parser 5: a Laravel app still locked on php-parser 4 (old psysh) needs
composer update psy/psysh.
Development
composer install composer test # unit and integration tests composer analyse # PHPStan, level max
The database integration tests run against real servers when these variables are set, and are skipped otherwise:
MIGRATION_RADAR_TEST_MYSQL_URL=mysql://root:radar@127.0.0.1/radar \ MIGRATION_RADAR_TEST_PGSQL_URL=pgsql://postgres:radar@127.0.0.1/radar \ vendor/bin/phpunit --testsuite integration
Architecture
src/
├── Domain/ # Migration model, database context, rules. Pure PHP, no dependency.
│ ├── Migration/ # What a migration does: operations, columns, ignore directives
│ ├── Database/ # Platform (engine + version), table sizes
│ └── Rule/ # Rule contract, issues, severities, one class per rule
├── Application/ # Use cases and the ports they need
│ ├── Analysis/ # AnalyseMigrations, SelectMigrations, BuildDatabaseContext, RuleSet, ReportRenderer
│ ├── Configuration/
│ └── Port/ # MigrationParser, MigrationFinder, ChangedFiles, DatabaseInspector
├── Infrastructure/ # Adapters: Laravel parser, PDO inspector, git, report formats, configuration file
└── Console/ # Symfony Console commands and the composition root (ServiceFactory)
Dependencies point inwards only: Domain depends on nothing, Application on Domain, Infrastructure on both.
CI enforces it with furybee/php-deps (php-deps.php).
Adding a rule: a class implementing Domain\Rule\Rule, registered in RuleSet::default(), with its unit test built on
Tests\Support\MigrationBuilder. Supporting another framework: an implementation of Application\Port\MigrationParser.