Last week I had an Artisan command that processed about 2,000 records. The first version used a transaction wrapper — if any single record failed, the whole batch rolled back. Clean, right?
Except when record #1,847 hit an edge case, all 1,846 successful records got nuked. That’s not clean. That’s a landmine.
The Fix: Per-Step Try/Catch
Instead of wrapping the entire loop in one big try/catch, wrap each iteration individually:
$records->each(function ($record) {
try {
$this->processRecord($record);
$this->info("✅ Processed #{$record->id}");
} catch (\Throwable $e) {
$this->error("❌ Failed #{$record->id}: {$e->getMessage()}");
Log::error("Batch process failed", [
'record_id' => $record->id,
'error' => $e->getMessage(),
]);
}
});
Why This Matters
The all-or-nothing approach feels safer because it’s “atomic.” But for batch operations where each record is independent, it’s actually worse. One bad record shouldn’t hold 1,999 good ones hostage.
The status symbols (✅/❌) aren’t just cute either. When you’re watching a command chug through thousands of records, that visual feedback tells you instantly if something’s going sideways without reading log files.
When to Use Which
Use transactions (all-or-nothing) when records depend on each other. Think: transferring money between accounts, or creating a parent record with its children.
Use per-step try/catch when each record is independent. Think: sending notification emails, syncing external data, or migrating legacy records.
The pattern is simple but I’ve seen teams default to transactions for everything. Sometimes the safest thing is to let the failures fail and keep the successes.









