Skip to main content
9 min read

Someone Ran migrate:fresh on Production

Someone Ran migrate:fresh on Production

Every Laravel team has the story, or knows a team that does. A terminal window pointed at the wrong environment. A deploy script with migrate:fresh left in from the prototype days. A --force flag added months ago to silence a CI prompt. And then: every table dropped, every row gone, on production.

php artisan migrate:fresh drops all tables and re-runs your migrations from zero. On your laptop it is the fastest way to a clean slate. On production it is the fastest way to a very bad week.

We built a Laravel 13 app with a production-looking dataset, ran the disaster on purpose, and timed both the damage and the recovery. The wipe took 21 seconds. The recovery, using point-in-time restore on Neon, took less than one. This post walks through the whole experiment so you can reproduce it, plus the guardrails that make the disaster much harder to trigger in the first place.

TL;DR

  • migrate:fresh --force wiped 5,000 customers and 25,000 orders in 21 seconds.
  • Recovery was a single API call to restore the branch to a timestamp: the call returned in 0.63 seconds, and the very next query read the recovered data.
  • The connection string never changed and the app needed no redeploy.
  • The broken state is preserved as a separate branch for forensics, so recovery destroys no evidence.
  • Nightly pg_dump cannot do this: your recovery point is the last dump, so you lose up to a day of writes. Point-in-time restore rewinds to any second inside the retention window.
  • Laravel ships a guardrail: DB::prohibitDestructiveCommands(). Turn it on.

Prerequisites

  • PHP 8.3+ and Composer (Laravel 13 requires PHP 8.3)
  • A Laravel app configured for Postgres
  • A project on Neon (the free plan covers this entire experiment)
  • A Neon API key for the restore call

The companion repo has the full app, seeder, and restore script:

The setup: a production that would hurt to lose

The demo app is a small orders system: customers and orders tables behind Eloquent models, plus a seeder that bulk-inserts a realistic dataset. An app:stats command prints what the database holds, which gives us proof at every step of the experiment.

// app/Console/Commands/AppStats.php
$this->table(
    ['customers', 'orders', 'revenue'],
    [[
        number_format(Customer::count()),
        number_format(Order::count()),
        '$' . number_format(Order::where('status', 'paid')->sum('total_cents') / 100, 2),
    ]]
);

Point .env at your Lakebase Postgres connection string (postgresql://...), migrate, and seed:

Five thousand customers, twenty-five thousand orders, $18.8M in recorded revenue. This is our production.

Before the disaster, note the current time. In a real incident you will reconstruct this from your monitoring or deploy logs, but it is the one input the recovery needs:

date -u +%Y-%m-%dT%H:%M:%SZ
# 2026-08-21T09:53:20Z

The disaster, timed

migrate:fresh drops every table in the database and re-runs all migrations. With --force it does not even ask for confirmation in production:

Twenty-one seconds, end to end. The schema is back, which makes it worse: the app boots, health checks pass, and every screen renders empty. Monitoring that only checks "can I connect and query" sees a healthy database.

Why your nightly dump does not save you

The classic answer is "restore from backup." The problem is not whether you have a backup. It is when the backup is from. With a nightly pg_dump, your recovery point is last night. Every order placed since then is gone, and on top of that you spend real time locating the dump, provisioning somewhere to restore it, and replaying it.

Recovery Point Objective (RPO) is the amount of data you accept losing, measured in time. Dump-based backups give you an RPO equal to your dump interval:

Point-in-time restore (PITR) changes the model. Instead of snapshots at intervals, the database keeps its full write history for a retention window, and you can rewind to any second inside it. Neon does this natively: storage is a log of every change, and a branch is a named position in that history. Restoring is not "replay a dump", it is "move the branch pointer."

The recovery: one API call

The restore is a single call against the branch, passing the timestamp you want to return to. The preserve_under_name parameter keeps the current (broken) state as its own branch instead of discarding it:

curl -X POST \
  -H "Authorization: Bearer $NEON_API_KEY" \
  -H "Content-Type: application/json" \
  "https://console.neon.tech/api/v2/projects/$PROJECT_ID/branches/$BRANCH_ID/restore" \
  -d '{
    "source_branch_id": "'$BRANCH_ID'",
    "source_timestamp": "2026-08-21T09:53:20Z",
    "preserve_under_name": "before-disaster-recovery"
  }'

Here is the measured recovery, straight from our run:

The API call returned in 0.63 seconds. The first app:stats after it read all 30,000 rows, revenue matching to the cent. Three details matter operationally:

  1. The connection string does not change. The endpoint moves with the branch, so the Laravel app needed no .env change, no redeploy, no restart. It was reading recovered data on its next query.
  2. No evidence is destroyed. The wiped state lives on as the before-disaster-recovery branch. You can connect to it later and work out exactly what ran and when, which your postmortem will thank you for.
  3. Restore time does not scale with database size. Nothing is copied or replayed. The branch pointer moves to a different position in history, which is why a 30,000-row demo and a 300 GB production database restore in roughly the same time.
Note

The rewind window is bounded by your project's history retention setting (the default is 1 day; paid plans can raise it). Anything older than the window is out of reach, so treat PITR as your fast first responder, not a replacement for long-term backups with a separate retention policy.

Guardrails: make the disaster hard to trigger

Recovery in under a second is great. Not needing it is better. Three layers, cheapest first.

1. Prohibit destructive commands in production. Laravel ships this switch, and it should be in every production app's AppServiceProvider:

use Illuminate\Support\Facades\DB;

public function boot(): void
{
    // Blocks migrate:fresh, migrate:refresh, migrate:reset and db:wipe
    // whenever APP_ENV is production, even with --force.
    DB::prohibitDestructiveCommands($this->app->isProduction());
}

With this enabled, migrate:fresh --force on production throws instead of dropping tables. It costs one line.

2. Separate the credentials. The migration user your deploy pipeline uses does not need DROP rights on every table. A role that can ALTER and CREATE but not DROP turns a fat-fingered command into a permissions error. On Neon you can also point staging and preview environments at branches instead of at production, so "wrong terminal" hits a copy, not the real thing.

3. Know your restore drill before you need it. The recovery above has three inputs: project ID, branch ID, timestamp. Put them in a runbook, script the call like the companion repo does, and run the drill once against a non-production branch. An incident is a bad time to read API docs for the first time.

Reproduce it yourself

The whole experiment is scripted in the companion repo: clone it, point .env at a fresh project on Neon, and you can run the disaster and the recovery in about five minutes. Wiping a database on purpose, and getting it back in under a second, is the kind of drill that permanently changes how your team thinks about backups.

git clone https://github.com/The-DevOps-Daily/neon-laravel-pitr-demo
cd neon-laravel-pitr-demo
composer install
cp .env.example .env && php artisan key:generate
# point DB_* at your Neon connection string, then follow README.md

Summary

  • migrate:fresh --force needs 21 seconds to erase a production database, and the app looks healthy afterwards because the schema survives.
  • Dump-based backups bound your loss to the dump interval. Point-in-time restore bounds it to seconds, because the storage keeps full write history inside a retention window.
  • On Neon the restore is one API call that moves the branch pointer: measured at 0.63 seconds, no connection string change, no redeploy, and the broken state preserved for the postmortem.
  • Turn on DB::prohibitDestructiveCommands(), split your migration credentials, and drill the restore once. The disaster that motivated this post should be a non-event on your team.

Run the commands from this article in the browser. Nothing to install.

Published: 2026-08-21|Last updated: 2026-08-21T09:00:00Z

Tags

DevOpsLaravelPostgresNeonDisaster RecoveryBackups

Found an issue?

Also worth your time on this topic