Catch dangerous database migrations before they reach production.
The strong_migrations equivalent for Laravel. Static analysis. Zero configuration. Framework-native.
Every Laravel team doing zero-downtime deployments has eventually had a migration incident. These operations succeed without errors in development, then cause production outages anywhere from immediately to hours later:
| Operation | What breaks in production |
|---|---|
dropColumn() |
Old app instances still query the dropped column — immediate DB errors during the deployment window |
NOT NULL without default |
Full table rewrite on MySQL < 8.0 — locks reads and writes for minutes on large tables |
renameColumn() |
Old instances use old name, new instances use new name — one of them is always wrong |
addIndex() without INPLACE |
MySQL < 8.0 holds a full write lock while building the index — minutes on busy tables |
change() column type |
Full table rewrite, potential silent data truncation (e.g. VARCHAR(50) → VARCHAR(40)) |
Schema::rename() |
Every Eloquent model and raw query referencing the old table name breaks immediately |
truncate() in a migration |
Production data permanently destroyed — migrations are the wrong place for data deletion |
Rails developers have had strong_migrations (4,000+ GitHub stars) for years. The Laravel ecosystem has no maintained equivalent. Every team solves this by hand: code review checklists, tribal knowledge, and hoping nobody forgets to check.
laravel-migration-guard eliminates that risk by making artisan migrate production-aware — without changing your workflow.
composer require --dev malikad778/laravel-migration-guardThe package auto-discovers via Laravel's package discovery. No manual registration required.
Optionally publish the config file:
php artisan vendor:publish --tag=migration-guard-configThat's it. Out of the box, with zero configuration, the guard:
- ✅ Hooks into
artisan migrateand warns before any dangerous migration runs - ✅ Is active only when
APP_ENVisproductionorstaging - ✅ Is completely silent in
localandtestingenvironments - ✅ Outputs warnings inline before execution, allowing you to abort with
Ctrl+C
The package uses static analysis — it parses your migration PHP files into an Abstract Syntax Tree (AST) using nikic/php-parser and walks the tree looking for dangerous method call patterns.
This means:
- No database connection needed — analysis works against raw PHP files in any environment, including CI/CD pipelines
- Sub-millisecond per file — PHP AST parsing is extremely fast; 200 migration files takes under a second
- Only the
up()method is analysed —down()rollbacks are intentionally excluded Schema::create()is excluded — creating a fresh table with no existing rows is always safe; onlySchema::table()operations are checked
Migration file
↓
PHP-Parser AST
↓
Extract up() method body
↓
Walk AST nodes (Schema::table / Schema::create context tracked)
↓
Run registered check visitors
↓
Collect Issue objects (severity, table, column, message, safe alternative)
↓
Console / JSON / GitHub Annotation reporter
Nine checks are included. All enabled by default, individually configurable.
| Check ID | Severity | What It Detects |
|---|---|---|
drop_column |
🔴 BREAKING | dropColumn() or dropColumns() on an existing table |
drop_table |
🔴 BREAKING | Schema::drop() or Schema::dropIfExists() |
rename_column |
🔴 BREAKING | renameColumn() on any table |
rename_table |
🔴 BREAKING | Schema::rename() |
modify_primary_key |
🔴 BREAKING | dropPrimary() or primary() on an existing table |
truncate |
🔴 BREAKING | DB::table()->truncate() inside a migration |
add_column_not_null |
🟡 HIGH | Column added without ->nullable() or ->default() |
change_column_type |
🟡 HIGH | ->change() modifying an existing column type |
add_index |
🔵 MEDIUM | Index added to a critical or large table |
// ❌ DANGEROUS
Schema::table('invoices', function (Blueprint $table) {
$table->dropColumn('amount');
});Why: During a zero-downtime deployment, old app instances run alongside the new schema. Any query touching the dropped column fails immediately with a database error.
Safe approach:
- Deploy 1: Remove all code references to the column (models, queries,
$fillable,$casts) - Deploy 2: Drop the column after confirming no running instance references it
// ❌ DANGEROUS — locks the table on MySQL < 8.0
Schema::table('users', function (Blueprint $table) {
$table->string('status');
});
// ✅ SAFE
Schema::table('users', function (Blueprint $table) {
$table->string('status')->nullable();
});Why: MySQL < 8.0 requires a full table rewrite when adding a NOT NULL column without a default. On a large table this blocks all reads and writes for minutes.
Safe approach:
- Add the column as
->nullable()(instant, no lock) - Backfill existing rows:
User::whereNull('status')->update(['status' => 'active']) - Add the
NOT NULLconstraint in a separate migration after backfill completes
// ❌ DANGEROUS
Schema::table('users', function (Blueprint $table) {
$table->renameColumn('name', 'full_name');
});
Schema::rename('users', 'customers');Why: Old instances use the old name, new instances use the new name — one is always wrong during the deployment window. Eloquent models, raw queries, and $fillable arrays all break.
Safe approach: Add new column → copy data → update code → deploy → drop old column in a follow-up migration.
// ⚠️ RISKY on tables with millions of rows
Schema::table('orders', function (Blueprint $table) {
$table->index('user_id');
});
// ✅ SAFE — use native syntax for online index creation
DB::statement('ALTER TABLE orders ADD INDEX idx_user_id (user_id) ALGORITHM=INPLACE, LOCK=NONE');Why: MySQL < 8.0 holds a full write lock while building an index. MySQL 8.0+ and PostgreSQL support online index builds but require specific syntax that Laravel migrations do not use by default.
// ❌ DANGEROUS
Schema::table('users', function (Blueprint $table) {
$table->string('bio', 100)->change(); // was VARCHAR(255)
});Why: A full table rewrite is required in most databases. Implicit type coercions can silently corrupt data (e.g. VARCHAR(255) → VARCHAR(100) truncates existing values). Indexes on the column may be dropped.
Safe approach: Add new column of the correct type → migrate data → update code → deploy → drop old column.
$ php artisan migrate
Running migrations...
┌──────────────────────────────────────────────────────────────┐
│ MIGRATION GUARD │ BREAKING │
└──────────────────────────────────────────────────────────────┘
File : 2024_01_15_000001_drop_amount_column.php
Line : 12
Check : drop_column
Table : invoices
Column : amount
Dropping column 'amount' from 'invoices' is dangerous.
Running app instances may still query this column.
Safe approach:
1. Remove code references to 'amount' in this deployment.
2. Drop the column in a follow-up deployment.
Continue anyway? [y/N]
<?php
// config/migration-guard.php
return [
// Environments where guard is active.
// Empty array = always active.
'environments' => ['production', 'staging'],
// 'warn' -> display warning, let developer abort with Ctrl+C
// 'block' -> throw exception, halt migration immediately
'mode' => env('MIGRATION_GUARD_MODE', 'warn'),
// Toggle individual checks on or off.
'checks' => [
'drop_column' => true,
'drop_table' => true,
'rename_column' => true,
'rename_table' => true,
'add_column_not_null' => true,
'change_column_type' => true,
'add_index' => true,
'modify_primary_key' => true,
'truncate' => true,
],
// Tables that always trigger extra scrutiny for index checks.
'critical_tables' => [
// 'users', 'orders', 'payments',
],
// Row count threshold for automatic large-table detection (requires live DB connection).
'row_threshold' => env('MIGRATION_GUARD_ROW_THRESHOLD', 500000),
// Suppress a specific check on a specific table or column.
// Use after confirming the operation is safe for your situation.
'ignore' => [
// ['check' => 'drop_column', 'table' => 'legacy_logs'],
// ['check' => 'add_column_not_null', 'table' => 'users', 'column' => 'migrated_at'],
],
];| Variable | Description |
|---|---|
MIGRATION_GUARD_MODE |
warn or block. Overrides config file. |
MIGRATION_GUARD_DISABLE |
Set to true to disable entirely (e.g. in CI seed steps). |
MIGRATION_GUARD_ROW_THRESHOLD |
Row count above which a table is treated as critical for index checks. Default: 500000. |
Standalone command for CI/CD pipelines. Analyses all pending migrations and outputs a report without running them. Exits with code 1 if dangerous operations are found.
# Analyse all pending migrations (default)
php artisan migration:guard:analyse
# JSON output — for GitLab Code Quality or custom tooling
php artisan migration:guard:analyse --format=json
# GitHub Actions annotations — inline PR diff comments
php artisan migration:guard:analyse --format=github
# Control when CI fails
php artisan migration:guard:analyse --fail-on=breaking # default
php artisan migration:guard:analyse --fail-on=high # BREAKING + HIGH
php artisan migration:guard:analyse --fail-on=any # all severities
php artisan migration:guard:analyse --fail-on=none # never fail (report only)
# Analyse all migrations, not just pending
php artisan migration:guard:analyse --pending-only=false
# Analyse a specific file or directory
php artisan migration:guard:analyse --path=database/migrations/2024_01_15_drop_column.phpExit codes:
| Code | Meaning |
|---|---|
0 |
No issues found, or all issues below --fail-on threshold |
1 |
One or more issues at or above threshold |
2 |
Analysis error (parse failure, permission error) |
Adds a suppression entry to config/migration-guard.php for a specific check and table — or check, table, and column.
# Suppress an entire table for a check
php artisan migration:guard:ignore drop_column legacy_logs
# → Added: ignore drop_column on table 'legacy_logs'
# Suppress a specific column on a specific table
php artisan migration:guard:ignore add_column_not_null users migrated_at
# → Added: ignore add_column_not_null on table 'users' column 'migrated_at'Valid check IDs: drop_column, drop_table, rename_column, rename_table, add_column_not_null, change_column_type, add_index, modify_primary_key, truncate
[
{
"check": "drop_column",
"severity": "breaking",
"file": "2024_01_15_000001_drop_amount_column.php",
"file_path": "/var/www/database/migrations/2024_01_15_000001_drop_amount_column.php",
"line": 12,
"table": "invoices",
"column": "amount",
"message": "Dropping column 'amount' from 'invoices' is dangerous.",
"safe_alternative": "Remove code references first. Drop in a follow-up deployment."
}
]# .github/workflows/migration-guard.yml
name: Migration Safety Check
on: [pull_request]
jobs:
migration-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --no-interaction --prefer-dist
- run: php artisan migration:guard:analyse --format=github --fail-on=breakingThe --format=github flag produces GitHub Actions annotation syntax, placing inline warnings directly on the pull request diff at the relevant migration file line.
migration-guard:
stage: test
script:
- composer install --no-interaction
- php artisan migration:guard:analyse --format=json > migration-guard-report.json
artifacts:
reports:
codequality: migration-guard-report.jsonsrc/
├── Checks/
│ ├── CheckInterface.php
│ ├── AbstractCheck.php ← isIgnored(), extractColumnsFromArgs() helpers
│ ├── DropColumnCheck.php
│ ├── DropTableCheck.php
│ ├── RenameColumnCheck.php
│ ├── RenameTableCheck.php
│ ├── AddColumnNotNullCheck.php
│ ├── ChangeColumnTypeCheck.php
│ ├── AddIndexCheck.php ← live DB row count query (v1.1.0+)
│ ├── ModifyPrimaryKeyCheck.php
│ └── TruncateCheck.php
├── Issues/
│ ├── Issue.php ← readonly DTO: checkId, severity, table, column…
│ └── IssueSeverity.php ← enum: BREAKING | HIGH | MEDIUM
├── Reporters/
│ ├── ReporterInterface.php
│ ├── ConsoleReporter.php
│ ├── JsonReporter.php
│ └── GithubAnnotationReporter.php
├── Commands/
│ ├── AnalyseCommand.php ← migration:guard:analyse
│ ├── IgnoreCommand.php ← migration:guard:ignore
│ ├── DigestCommand.php ← migration:guard:digest (v1.2.0)
│ └── FixCommand.php ← migration:guard:fix (v2.0.0)
├── Listeners/
│ └── MigrationStartingListener.php
├── MigrationAnalyser.php ← core: parse → traverse → collect issues
├── MigrationNodeVisitor.php ← AST visitor: tracks Schema::table context
├── MigrationContext.php ← current table name + isCreate flag
└── MigrationGuardServiceProvider.php
| Version | |
|---|---|
| PHP | 8.2 or higher |
| Laravel | 10.x, 11.x, 12.x |
| MySQL | 5.7+ or 8.0+ |
| PostgreSQL | 13+ |
| SQLite | 3+ |
nikic/php-parser |
^5.0 (installed automatically) |
| Feature | strong_migrations | laravel-migration-guard |
|---|---|---|
| Drop column detection | ✅ | ✅ |
| Drop table detection | ✅ | ✅ |
| Rename detection | ✅ | ✅ |
| NOT NULL without default | ✅ | ✅ |
| Index safety | ✅ | ✅ |
| CI/CD JSON output | ✅ | ✅ |
| GitHub Annotations | ❌ | ✅ |
| Per-table suppression | ✅ | ✅ |
| Per-column suppression | ❌ | ✅ |
| Warn vs Block mode | ✅ | ✅ |
| Zero config defaults | ✅ | ✅ |
| Framework | Rails only | Laravel only |
v1.0.0 — Launch (current)
- All 9 checks fully implemented and tested
artisan migratehookmigration:guard:analysewith table, JSON, GitHub outputmigration:guard:ignorecommand- Full documentation
v1.1.0 — Database Awareness
- Query the live database to get actual row counts for index safety thresholds
- Show estimated lock duration based on table size
- PostgreSQL-specific checks:
CONCURRENTLYindex builds,VACUUMconsiderations
v1.2.0 — Reporting
- Weekly migration safety digest: summary of all migrations run in the past 7 days
- Slack / email notification when dangerous migrations are bypassed in production
- Audit log of every migration run with who triggered it
v2.0.0 — Safe Alternative Code Generation
- For each detected issue, generate the safe equivalent migration stub automatically
migration:guard:fixcommand that rewrites the migration file with the safe pattern
Contributions are welcome. Adding a new check requires only:
- Create a class implementing
CheckInterfaceinsrc/Checks/ - Register it in
MigrationGuardServiceProvider::register() - Add the check ID to the
checksarray inconfig/migration-guard.php - Write unit tests covering both the unsafe pattern and the safe equivalent (false positive tests are required)
# Run the test suite
./vendor/bin/pest
# Run with coverage
./vendor/bin/pest --coverageMIT — free forever. See LICENSE.md.
Made for the Laravel ecosystem · Inspired by strong_migrations
