Every long-lived Laravel project ends up with an app/Console/Commands folder full of unsung heroes. The nightly invoice generator. The users:backfill-stripe command someone wrote one Friday afternoon. The support:reset-password {user} your CTO sometimes runs from SSH. These commands aren't glamorous, but they're the difference between "we operate the app" and "we hope the app keeps working."
The tricky part is that commands are how production gets edited by hand. A bad command can corrupt a database, double-charge customers, or silently swallow a failure. A good command argues for the right inputs, refuses to do anything destructive without confirmation, and tells you exactly what happened when it's done.
The Shape Of A Command That Survives
The skeleton is generated for you:
php artisan make:command BackfillStripeCustomers --command=customers:backfill-stripe
You almost always want --command= to set the explicit name; the auto-generated app: prefix isn't useful for anything that's actually operational.
The pieces that matter live in two places — the signature and the handle() method:
namespace App\Console\Commands;
use App\Actions\Customers\BackfillStripe;
use Illuminate\Console\Command;
final class BackfillStripeCustomers extends Command
{
protected $signature = 'customers:backfill-stripe
{--limit=100 : Maximum customers to process}
{--since= : ISO date — only customers updated after}
{--dry-run : Print what would change, do nothing}';
protected $description = 'Sync local customers into Stripe in batches';
public function handle(BackfillStripe $action): int
{
$opts = [
'limit' => (int) $this->option('limit'),
'since' => $this->option('since'),
'dryRun' => (bool) $this->option('dry-run'),
];
$this->components->info("Backfilling up to {$opts['limit']} customers");
if ($opts['dryRun']) {
$this->components->warn('Dry run — no writes will happen');
}
$report = $action->execute($opts);
$this->components->twoColumnDetail('Created', $report->created);
$this->components->twoColumnDetail('Updated', $report->updated);
$this->components->twoColumnDetail('Skipped', $report->skipped);
$this->components->twoColumnDetail('Failed', $report->failed);
return $report->failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
Three things are doing work here. The signature documents the inputs in one place, including a {--dry-run} flag that should be a default reflex on any command that writes. The handle() delegates to an Action class — the same class your HTTP controller would call — so the business logic isn't duplicated. And the return value is the exit code, which is what your scheduler, CI, and shell-script callers actually check.
return self::SUCCESS; and return self::FAILURE; are the only two values you should ever return from a command. Don't return 0 or 1 directly — the constants make the intent obvious.
Arguments, Options, And Saying No To Bad Inputs
The signature mini-language is more capable than people give it credit for:
protected $signature = 'orders:resend
{order : Order ID to resend}
{emails* : One or more email addresses}
{--cc= : Optional CC list, comma-separated}
{--force : Skip confirmation}';
order is required, emails* accepts one or more positional values, --cc is an optional string, and --force is a boolean flag. That's most of what you need.
What the signature doesn't do is validate semantically. For that, you reach for normal Laravel validation inside handle():
$validated = validator(
[
'order' => $this->argument('order'),
'emails' => $this->argument('emails'),
],
[
'order' => ['required', 'integer', 'exists:orders,id'],
'emails' => ['required', 'array', 'min:1'],
'emails.*' => ['email'],
]
)->validate();
The same rules you'd put on a FormRequest, applied to the same kind of input. If the operator types a typo, they get a real error message and the command exits without touching the database.
Prompts For The Things You'd Regret
For interactive commands — anything an engineer might run against production — install Laravel Prompts (it ships with Laravel 11+):
use function Laravel\Prompts\{confirm, text, select, spin};
$tenant = select(
label: 'Which tenant?',
options: Tenant::pluck('name', 'id')->all(),
required: true,
);
if (! confirm("Reset password for tenant {$tenant} admin? This emails them.")) {
$this->components->warn('Aborted.');
return self::INVALID;
}
$result = spin(fn () => $action->execute($tenant), 'Resetting password...');
The base $this->confirm(), $this->ask(), $this->choice(), and $this->secret() methods still work and are perfectly fine for simple cases — secret() in particular for any password input so it doesn't show up in shell history. Prompts is the upgrade when you want validation, multiselects, multiline input, and spinners that don't look like they were written in 2014.
The key habit, regardless of which API you use: anything destructive gets a confirm. If a command can delete, truncate, force, or reset, the only acceptable shortcut is --force on the signature, and even then I usually still confirm unless the script that calls the command is the only intended caller.
Progress, Tables, And Output That Helps Future You
The output APIs are well-designed; use them.
$this->components->info('Starting export');
$this->components->task('Fetching orders', fn () => $this->fetchOrders());
$this->components->task('Building CSV', fn () => $this->buildCsv());
$this->withProgressBar(Order::pending()->cursor(), function ($order) {
$this->processOne($order);
});
$this->newLine(2);
$this->components->table(
['Status', 'Count'],
[
['Created', $report->created],
['Failed', $report->failed],
]
);
task() prints a green check or a red cross. withProgressBar() works on cursors and lazy collections, not just arrays — which matters when you're iterating ten million rows and don't want to load them all into memory. table() is the most readable summary you can leave at the end of a command.
For machine-readable output (when another script consumes you), accept --json or --quiet and write only structured data on stdout, with everything else going to stderr via $this->error(). That separation is what lets your command be both human-friendly and pipeable.
Idempotency And Restartability
The single most useful property of an operational command is "I can run it again and it does the right thing." Backfills get killed halfway. Imports OOM at row 873 of 10,000. The command must come back tomorrow and pick up where it left off without redoing or skipping work.
Two patterns cover most cases:
// 1. Mark-and-skip
foreach ($users as $user) {
if ($user->stripe_id) {
continue;
}
$user->update(['stripe_id' => Stripe::createCustomer($user)->id]);
}
// 2. Cursor with persisted checkpoint
$lastId = Cache::get('backfill:last_id', 0);
User::where('id', '>', $lastId)
->orderBy('id')
->chunkById(500, function ($chunk) {
foreach ($chunk as $user) { $this->processOne($user); }
Cache::put('backfill:last_id', $chunk->last()->id);
});
The first works when there's a natural marker (a column that goes from null to non-null). The second works when there isn't — you store progress out-of-band and resume from it. Either way, run the command, kill it with Ctrl-C, run it again, and verify it does the right thing. That's the test.
Calling Other Commands And Dispatching Jobs
Commands are full Laravel citizens. They can dispatch jobs, fire events, call other commands, and queue themselves to run later.
$this->call('cache:clear');
$this->callSilently('queue:restart');
ProcessHeavyExport::dispatch($tenant);
dispatch(fn () => SomeQuickWork::run())->afterResponse();
The pattern that comes up a lot in operational scripts: command kicks off a queued job, then waits for it to finish or polls for status. That's fine when the work is heavy enough to deserve a queued worker, and overkill when it isn't. A good rule: if the work fits in a single command run under a couple of minutes, do it inline. If it doesn't, dispatch the job and have the command return a job ID the operator can check.
When To Make A Command Vs A Job Vs A Route
The same logic could live in three places. The signal:
- Make it a command when it's something a human might run, on demand or on a schedule, and they want to see the output. Imports, exports, backfills, repairs, reports.
- Make it a queued job when it's a side effect of something else — a webhook fired it, a controller dispatched it, the scheduler called it. The job runs invisibly and reports through Horizon.
- Make it a route when it's part of the application's contract with users.
The mistake to avoid is putting the actual work inside the command class. Put it in an Action service. The command parses arguments, delegates, formats output, returns an exit code. The Action does the work and is callable from a controller, a queued job, a test, or another command. That's the boundary that pays back the most over time.
A Laravel codebase you can safely operate at 3 AM is a codebase where the commands are short, the actions are tested, the signatures are unambiguous, and the exit codes are honest. None of that is hard. It just has to be done deliberately, before the operational scripts grow into the thing you're afraid to touch.




