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 --forcewiped 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_dumpcannot 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:
- The connection string does not change. The endpoint moves with the branch, so the Laravel app needed no
.envchange, no redeploy, no restart. It was reading recovered data on its next query. - No evidence is destroyed. The wiped state lives on as the
before-disaster-recoverybranch. You can connect to it later and work out exactly what ran and when, which your postmortem will thank you for. - 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.
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 --forceneeds 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.
Try it hands-on
Run the commands from this article in the browser. Nothing to install.
We earn commissions when you shop through the links below.
Svix
Webhooks as a service
Svix Dispatch sends your webhooks for you: retries with exponential backoff, signed payloads, idempotency keys, and a delivery log your customers can see.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
SMTPfast
Developer-first email API
Send transactional and marketing email through a clean REST API. Detailed logs, webhooks, and embeddable signup forms in one dashboard.
QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?
Related Posts
Also worth your time on this topic
A Postgres Branch Per Learner: Building on Neon
Every hands-on lab gets its own Postgres branch, AI generation runs outside the request cycle, and cleanup is core infrastructure rather than a chore.
Database Backup and Recovery
Describe database backup strategies and how you would design a recovery plan for production databases.
mid
CI/CD Pipeline Setup Checklist
Step-by-step checklist for a production-ready CI/CD pipeline: source control, builds, tests, security scans, deploy gates, secrets, and rollback paths.
1-2 hours