Table of Contents
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:
- Dry-run flag with transactions – Wrap everything in
DB::beginTransaction(), then rollback if--dry-run, commit if live - Optional positional arguments – Let devs pass specific IDs for local testing instead of hardcoding production values
- Per-step try-catch – Don’t halt on first error; capture and continue so you get full visibility
- Progress bars – Use
$this->output->progressStart()for UX during long runs - Detailed result tables – Show success/failure/skipped per step per record with symbols (β β β)
- 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.
Leave a Reply