Table of Contents
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:
- Dry-run flag that wraps everything in a transaction and rolls back
- Optional arguments to override data (for local testing)
- Per-step error handling that doesn’t halt execution
- Progress bars for long operations
- 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.
Leave a Reply