Skip to main content
← All use cases

How to Monitor php artisan migrate in Production

php artisan migrate is one of the riskiest deploy steps in a Laravel app. It can run for minutes, fail halfway through, or succeed while still causing lock contention and rollout problems on the live database.

If you’re searching for how to monitor php artisan migrate in production, the real goal is to catch failures, long-running schema changes, and rollback problems before they take your site down or corrupt data.

The dangerous part is that a migration can exit non-zero, take far longer than expected, or leave the app in a state that only shows up under live traffic.

How migrate Actually Works

The php artisan migrate command runs all outstanding migrations in order. Each migration is a pair of up() and down() methods that define a schema change. Laravel tracks which migrations have run in the migrations table by storing a single row per migration file with its batch number.

Migrations run in a transaction by default in MySQL if your tables support it (InnoDB). But this is where it gets tricky: adding a column to a table with millions of rows does an ALTER TABLE that cannot be rolled back within a transaction in MySQL. Laravel detects this and runs the migration without a transaction, meaning a partial failure leaves your database in an inconsistent state.

Laravel 10 and later fire events you can hook into for monitoring:

use Illuminate\Database\Events\MigrationsEnded;
use Illuminate\Database\Events\MigrationsStarting;

Event::listen(MigrationsStarting::class, function ($event) {
    Log::info('Migrations starting', ['pretending' => $event->pretending]);
});

Event::listen(MigrationsEnded::class, function ($event) {
    Log::info('Migrations ended', ['scope' => $event->scope]);
});

These fire regardless of whether you’re using the --pretend flag, giving you a hook for monitoring without modifying your migration files.

Common Failure Modes

Destructive migrations run in the wrong order. If two developers write migrations that both modify the same table and they get applied in an unexpected order, the second migration may reference columns that the first one renamed or dropped. The migration fails, and depending on whether it was in a transaction, your database may be left partially migrated.

Long-running migrations lock the database. Adding an index to a large table in MySQL acquires a shared lock that blocks writes. Adding a NOT NULL column without a default blocks reads and writes for the entire duration. These operations can take minutes on large tables, and your application will timeout waiting for the database during that window.

Rollback runs when it shouldn’t. If your deployment tool runs migrate and your rollback tool runs migrate:rollback separately, an accidental rollback after a successful migration can undo changes you intended to keep. This is especially dangerous in multi-region deployments where rollback completes in one region but hasn’t started in another.

Missing dependencies. A migration that adds a column and references it in a foreign key constraint will fail if the parent table migration hasn’t run yet. This is usually caught in testing, but it occasionally slips through when the test environment has a different migration history than production.

Building Visibility Into Migrations

Use the migrate event hooks to send a heartbeat at the start and end of each migration run:

// In AppServiceProvider or a dedicated event service provider
Event::listen(MigrationsStarting::class, function () {
    Http::get(config('services.monitor.migrate_url') . '/started');
});

Event::listen(MigrationsEnded::class, function ($event) {
    Http::get(config('services.monitor.migrate_url') . '/ended?' . http_build_query([
        'scope' => $event->scope,
        'pretending' => $event->pretending,
    ]));
});

If the “ended” ping never arrives after a migration run, the process crashed. Crontinel tracks this and fires an alert.

You can also track migration duration by measuring the time between start and end pings. A migration that normally takes 5 seconds but suddenly takes 45 minutes is likely hitting a lock or running a slow ALTER TABLE.

For migrations that are expected to be long, track progress through the individual migration files:

// In a long-running migration
use Illuminate\Support\Facades\DB;

public function up()
{
    $total = DB::table('orders')->count();
    $processed = 0;

    DB::table('orders')->orderBy('id')->chunk(1000, function ($orders) use (&$processed, $total) {
        // Process batch
        $processed += $orders->count();
        Http::get(config('services.monitor.migrate_url') . '/progress?' . http_build_query([
            'model' => 'Order',
            'processed' => $processed,
            'total' => $total,
        ]));
    });
}

This gives you visibility into migration progress even for operations that take hours.

Detecting Migration Problems

The clearest signal of a migration problem is an unusual deployment duration. If your CI pipeline normally deploys in 8 minutes and suddenly takes 45, a migration is the first thing to check.

After every production migration, verify that the application can boot correctly by running a health check endpoint that exercises the new schema:

php artisan migrate --pretend && \
curl -f https://your-app.com/health/database

The health check should run a query against the new schema. If it fails, the migration broke something. This is faster than waiting for a user to hit the error page.

Production migration monitoring is about knowing whether the migration ran, whether it succeeded, and whether it caused collateral damage to your application’s response times. A few event hooks and a health check cover all three.

See also

Start monitoring in minutes

Free for one app. No account needed to install and test locally.

composer require crontinel/laravel
php artisan crontinel:install
Get early access