Laravel Artisan Commands: The Dry-Run Pattern for Production Safety

πŸ“– 3 minutes read

Building one-off Artisan commands that touch production data? Here’s a battle-tested pattern that’s saved me from disaster more than once.

The Pattern

When you need to bulk-process records in production, implement these six safeguards:

  1. Dry-run flag with transactions – Wrap everything in DB::beginTransaction(), then rollback if --dry-run, commit if live
  2. Optional positional arguments – Let devs pass specific IDs for local testing instead of hardcoding production values
  3. Per-step try-catch – Don’t halt on first error; capture and continue so you get full visibility
  4. Progress bars – Use $this->output->progressStart() for UX during long runs
  5. Detailed result tables – Show success/failure/skipped per step per record with symbols (βœ“ βœ— ⊘)
  6. Validation constants – Define expected ‘before’ states as consts and skip records that don’t match

The Code


namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class ProcessOrdersBulk extends Command
{
    protected $signature = 'orders:process-bulk {ids?*} {--dry-run}';
    protected $description = 'Process orders in bulk with safety checks';

    const EXPECTED_STATUS = 'pending';

    public function handle()
    {
        $isDryRun = $this->option('dry-run');
        $orderIds = $this->argument('ids') ?: Order::where('status', self::EXPECTED_STATUS)->pluck('id');

        $this->info($isDryRun ? '🟑 DRY RUN MODE' : 'πŸ”΄ LIVE MODE');
        
        DB::beginTransaction();
        
        $results = [];
        $this->output->progressStart(count($orderIds));

        foreach ($orderIds as $id) {
            $result = ['step_1' => '', 'step_2' => '', 'step_3' => ''];
            
            try {
                $order = Order::findOrFail($id);
                
                // Validation: skip if not in expected state
                if ($order->status !== self::EXPECTED_STATUS) {
                    $result['step_1'] = '⊘ Wrong status';
                    $result['step_2'] = '⊘ Skipped';
                    $result['step_3'] = '⊘ Skipped';
                    $results[$id] = $result;
                    $this->output->progressAdvance();
                    continue;
                }
                
                // Step 1: Calculate totals
                try {
                    $order->calculateTotal();
                    $result['step_1'] = 'βœ“ Calculated';
                } catch (\Throwable $e) {
                    $result['step_1'] = 'βœ— ' . $e->getMessage();
                }
                
                // Step 2: Send notification
                try {
                    $order->sendProcessingEmail();
                    $result['step_2'] = 'βœ“ Notified';
                } catch (\Throwable $e) {
                    $result['step_2'] = 'βœ— ' . $e->getMessage();
                }
                
                // Step 3: Update status
                try {
                    $order->update(['status' => 'processed']);
                    $result['step_3'] = 'βœ“ Updated';
                } catch (\Throwable $e) {
                    $result['step_3'] = 'βœ— ' . $e->getMessage();
                }
                
            } catch (\Throwable $e) {
                $result['step_1'] = 'βœ— Order not found';
                $result['step_2'] = '⊘ Skipped';
                $result['step_3'] = '⊘ Skipped';
            }
            
            $results[$id] = $result;
            $this->output->progressAdvance();
        }
        
        $this->output->progressFinish();
        
        // Display results table
        $rows = [];
        foreach ($results as $id => $steps) {
            $rows[] = [$id, $steps['step_1'], $steps['step_2'], $steps['step_3']];
        }
        
        $this->table(['Order ID', 'Calculate', 'Notify', 'Update'], $rows);
        
        // Rollback on dry-run, commit on live
        if ($isDryRun) {
            DB::rollback();
            $this->warn('🟑 Rolled back (dry-run mode)');
        } else {
            DB::commit();
            $this->info('βœ“ Committed to database');
        }
        
        return 0;
    }
}

Why This Works

The per-step error handling is the key. Instead of aborting when step 1 fails on record 50, you get visibility into all 100 records. Maybe step 1 works but step 2 fails for specific records – now you know exactly which ones and why.

The dry-run flag lets you test the full execution path with real data without committing changes. Combined with optional ID arguments, you can test locally with production-like scenarios.

Real-World Usage


# Local testing with specific IDs
php artisan orders:process-bulk 101 102 103 --dry-run

# Dry-run on staging with production data
php artisan orders:process-bulk --dry-run

# Live run (no dry-run flag)
php artisan orders:process-bulk

This pattern prevents catastrophic errors while maintaining full auditability. When you’re touching 100+ production records, you want safety AND visibility – not all-or-nothing execution.

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 *