Dry-Run Mode with Transaction Rollback

πŸ“– 3 minutes read

You’re about to run a one-off script that updates 10,000 database records. You’ve tested it on staging. You’ve code-reviewed it. But you still want to see exactly what it’ll do in production before committing.

Enter: the dry-run transaction pattern.

The Problem with Fake Dry-Runs

Most “dry-run” modes look like this:

if ($dryRun) {
    $this->info("Would update order #{$order->id}");
} else {
    $order->update(['status' => 'processed']);
}

The issue? You’re not running the real code. If there’s a bug in the actual update logic β€” a constraint violation, a triggered event that fails, a missing column β€” you won’t find out until you run it for real.

The Transaction Rollback Trick

Instead, run the actual code path, but wrap it in a transaction and roll it back:

use Illuminate\Support\Facades\DB;

class ProcessOrdersCommand extends Command
{
    public function handle()
    {
        $dryRun = $this->option('dry-run');
        
        if ($dryRun) {
            $this->warn('πŸ” DRY-RUN MODE β€” changes will be rolled back');
            DB::beginTransaction();
        }
        
        try {
            $orders = Order::pending()->get();
            
            foreach ($orders as $order) {
                $order->update(['status' => 'processed']);
                $this->info("βœ“ Processed order #{$order->id}");
            }
            
            if ($dryRun) {
                DB::rollback();
                $this->info('βœ… Dry-run complete β€” no changes saved');
            } else {
                $this->info('βœ… All changes committed');
            }
            
        } catch (\Exception $e) {
            if ($dryRun) {
                DB::rollback();
            }
            throw $e;
        }
    }
}

What You Get

With this pattern:

  • Real execution: Every query runs, every constraint is checked
  • Event triggers fire: Model observers, jobs, notifications β€” all execute
  • Nothing persists: Rollback undoes all database changes
  • Safe preview: See exactly what would happen, but commit nothing

Bonus: Track What Changed

Want to see before/after values?

$changes = [];

foreach ($orders as $order) {
    $original = $order->toArray();
    $order->update(['status' => 'processed']);
    $changes[] = [
        'id' => $order->id,
        'before' => $original['status'],
        'after' => $order->status
    ];
}

if ($dryRun) {
    $this->table(['ID', 'Before', 'After'], 
        array_map(fn($c) => [$c['id'], $c['before'], $c['after']], $changes)
    );
}

Now your dry-run shows a summary table of what would change.

When Not to Use This

This pattern doesn’t help with:

  • External API calls: Transaction rollback won’t undo HTTP requests
  • File operations: Transactions don’t cover filesystem changes
  • Email/notifications: Side effects outside the database still fire

For those, you still need conditional logic or feature flags.

The Takeaway

Dry-run via transaction rollback tests your real code path. If it works in dry-run, it works for real. No surprises, no “but it worked in staging” excuses.

Add --dry-run to every risky command. Your production database will thank you.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *