Artisan Commands with Dry-Run Mode for Safe Production Bulk Operations

📖 3 minutes read

Production data migrations can be terrifying. One wrong command and you’re restoring from backups. Here’s a pattern I use for critical bulk operations: dry-run mode with database transactions.

The Pattern

Build your Artisan command with these 5 features:

  1. Dry-run flag that wraps everything in a transaction and rolls back
  2. Optional arguments to override data (for local testing)
  3. Per-step error handling that doesn’t halt execution
  4. Progress bars for long operations
  5. Detailed table output showing success/failure/skipped per record

Example Command

namespace App\Console\Commands;

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

class ProcessBulkInvoices extends Command
{
    protected $signature = 'invoices:process-bulk {codes?*} {--dry-run}';
    protected $description = 'Process multiple invoices in bulk';

    const EXPECTED_STATUS = 'pending';

    public function handle()
    {
        $codes = $this->argument('codes') ?: $this->getDefaultCodes();
        $isDryRun = $this->option('dry-run');

        if ($isDryRun) {
            DB::beginTransaction();
            $this->warn('🧪 DRY-RUN MODE - Changes will be rolled back');
        }

        $results = [];
        $bar = $this->output->createProgressBar(count($codes));

        foreach ($codes as $code) {
            $result = [
                'code' => $code,
                'validate' => '',
                'process' => '',
                'notify' => ''
            ];

            // Step 1: Validate status
            try {
                $invoice = Invoice::where('code', $code)->first();
                
                if (!$invoice) {
                    $result['validate'] = '✗ Not found';
                    $results[] = $result;
                    $bar->advance();
                    continue;
                }

                if ($invoice->status !== self::EXPECTED_STATUS) {
                    $result['validate'] = "⊘ Status is {$invoice->status}";
                    $results[] = $result;
                    $bar->advance();
                    continue;
                }

                $result['validate'] = '✓ OK';
            } catch (\Throwable $e) {
                $result['validate'] = '✗ ' . $e->getMessage();
                $results[] = $result;
                $bar->advance();
                continue;
            }

            // Step 2: Process invoice
            try {
                $invoice->markAsProcessed();
                $invoice->addNote('Bulk processed via command', auth()->id());
                $result['process'] = '✓ Processed';
            } catch (\Throwable $e) {
                $result['process'] = '✗ ' . $e->getMessage();
            }

            // Step 3: Send notification
            try {
                $invoice->customer->notify(new InvoiceProcessed($invoice));
                $result['notify'] = '✓ Sent';
            } catch (\Throwable $e) {
                $result['notify'] = '✗ ' . $e->getMessage();
            }

            $results[] = $result;
            $bar->advance();
        }

        $bar->finish();
        $this->newLine(2);

        // Display results table
        $this->table(
            ['Code', 'Validate', 'Process', 'Notify'],
            collect($results)->map(fn($r) => [
                $r['code'],
                $r['validate'],
                $r['process'],
                $r['notify']
            ])
        );

        if ($isDryRun) {
            DB::rollBack();
            $this->warn('🧪 All changes rolled back (dry-run mode)');
        } else {
            $this->info('✅ Bulk operation complete');
        }

        return 0;
    }

    private function getDefaultCodes(): array
    {
        // Hard-coded list for production, overridable via argument
        return ['INV-001', 'INV-002', 'INV-003'];
    }
}

Usage

# Test locally with custom codes
php artisan invoices:process-bulk INV-TEST-001 INV-TEST-002 --dry-run

# Dry-run on production data (safe)
php artisan invoices:process-bulk --dry-run

# Actually run it (scary, but you tested it first)
php artisan invoices:process-bulk

Why This Works

Transactions prevent mistakes. Dry-run mode wraps everything in a transaction and rolls back at the end. You see exactly what would happen, zero risk.

Per-step errors don’t halt execution. If invoice #42 fails, invoice #43 still runs. You get partial completion instead of catastrophic failure.

Status validation prevents double-processing. The EXPECTED_STATUS const ensures you only touch records in the correct state. Already processed? Skip it with ⊘.

Table output is auditable. You get a clear record of exactly what happened to each item. Screenshot it, paste it in Slack, attach it to the ticket.

Bonus: Argument Override

The {codes?*} argument lets you override the default list. This is crucial for local testing – you don’t want to hardcode production invoice codes in your dev database. Pass in test codes instead.

# Local testing with dev database
php artisan invoices:process-bulk INV-DEV-001 --dry-run

This pattern has saved me from production disasters more times than I can count. Dry-run first. Always.

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 *