- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You need to regenerate last month's billing report. There's an artisan command for it. You run php artisan report:generate --help, and it tells you almost nothing. So you guess:
php artisan report:generate 2026-05 3 true us-east
It exits 0. No output. Twenty minutes later someone asks why the report is empty, and you find out 3 was supposed to be a customer tier, true toggled a draft flag you didn't want, and the region string had to be us-east-1. The command didn't validate anything. It just did what you told it, silently, wrongly.
Every team has a command like this. The person who wrote it knows the argument order by heart. Nobody else touches it without Slacking them first. That's a UX failure, and Laravel Prompts fixes most of it.
Laravel Prompts is a handful of functions
laravel/prompts ships with Laravel and works in any PHP 8.4 CLI, framework or not. You import functions and call them. Each one returns a typed value:
use function Laravel\Prompts\text;
use function Laravel\Prompts\select;
use function Laravel\Prompts\confirm;
$name = text(label: 'Report name?');
$tier = select(label: 'Tier?', options: ['bronze', 'gold']);
$draft = confirm(label: 'Save as draft?', default: false);
No positional guessing. The person running the command sees the question, the allowed answers, and the default. That alone kills the class of bug in the opener.
A command that asks instead of assuming
Here is the report command rewritten. The positional arguments become options, and anything missing gets asked for out loud:
<?php
namespace App\Console\Commands;
use App\Reports\GenerateReport;
use App\Reports\ReportCriteria;
use Illuminate\Console\Command;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\select;
use function Laravel\Prompts\text;
class GenerateReportCommand extends Command
{
protected $signature = 'report:generate
{--period=} {--tier=} {--region=} {--draft}';
protected $description = 'Generate a billing report';
public function handle(GenerateReport $useCase): int
{
$criteria = $this->askForCriteria();
$report = $useCase->execute($criteria);
$this->components->info(
"Report ready: {$report->url}"
);
return self::SUCCESS;
}
}
The command's own job is small: collect a valid ReportCriteria, hand it to the use case, print the result. It does not know how billing works. It knows how to ask.
Validation at the door, not deep in the code
The best place to reject bad input is the moment someone types it. Prompts take a validate closure that returns an error string or null. The prompt re-asks until the answer passes:
private function askForCriteria(): ReportCriteria
{
$period = text(
label: 'Billing period?',
placeholder: '2026-05',
validate: fn (string $v) => preg_match(
'/^\d{4}-\d{2}$/', $v
) ? null : 'Use YYYY-MM.',
);
$tier = select(
label: 'Customer tier?',
options: ['bronze', 'silver', 'gold'],
);
$region = select(
label: 'Region?',
options: ['us-east-1', 'eu-west-1'],
);
$draft = confirm(
label: 'Save as draft?',
default: false,
);
return new ReportCriteria(
$period, $tier, $region, $draft
);
}
select makes an invalid region impossible to type. validate on the period rejects 2026-5 before any query runs. If you already have Laravel validation rules, pass them straight through instead of writing a closure:
$period = text(
label: 'Billing period?',
validate: ['period' => 'required|date_format:Y-m'],
);
The ReportCriteria that comes out the other side is always well-formed. The use case never receives garbage, so it never has to defend against it.
spin() and progress() so the terminal stops lying
A command that prints nothing for twenty minutes looks broken even when it's working. Two functions fix that.
spin() runs a callback while showing an animated message. It returns whatever the callback returns:
use function Laravel\Prompts\spin;
$report = spin(
message: 'Crunching invoices...',
callback: fn () => $useCase->execute($criteria),
);
One caveat worth knowing: spin() needs the pcntl extension to animate. Without it the callback still runs and the message still shows, it just doesn't spin. That's fine on most Linux hosts and safe to rely on.
When you can count the work, progress() beats a spinner because it shows how far along you are:
use function Laravel\Prompts\progress;
$progress = progress(
label: 'Rendering statements',
steps: $customers->count(),
);
$progress->start();
foreach ($customers as $customer) {
$renderer->render($customer, $period);
$progress->advance();
}
$progress->finish();
Now the person watching knows the difference between "347 of 12,000, moving" and "stuck." That distinction is the whole reason anyone stares at a terminal.
The CI problem: prompts hang or explode
Here's where naive prompt code breaks. Your nightly pipeline runs the same command:
php artisan report:generate --no-interaction
There's no human to answer text(). Under --no-interaction, Laravel falls back to Symfony's question helper, which returns the default. A prompt marked required with no default throws instead. Either way, a command built purely on prompts is a command that can't run unattended. Cron can't pick a tier from a menu.
The mistake is treating the prompt as the source of truth. It isn't. The criteria is. The prompt is one way to fill it in.
One command, two front doors
Read the options first. Prompt only for what's missing, and only when a human is actually there:
private function askForCriteria(): ReportCriteria
{
$period = $this->option('period') ?? $this->promptFor(
fn () => text(
label: 'Billing period?',
validate: [
'period' => 'required|date_format:Y-m',
],
),
'period',
);
$tier = $this->option('tier') ?? $this->promptFor(
fn () => select(
label: 'Customer tier?',
options: ['bronze', 'silver', 'gold'],
),
'tier',
);
// region, draft: same shape
return new ReportCriteria(
$period,
$tier,
$this->option('region') ?? 'us-east-1',
(bool) $this->option('draft'),
);
}
The helper is named promptFor on purpose. Illuminate\Console\Command already ships a public ask(), so redeclaring it as private is a class-load fatal error. This one prompts when a human is present and throws when one isn't:
private function promptFor(callable $prompt, string $name): string
{
if (! $this->input->isInteractive()) {
throw new \RuntimeException(
"Missing --{$name} in non-interactive mode."
);
}
return $prompt();
}
Now the command has two front doors into the same use case. A developer runs it bare and gets guided through prompts. CI runs it with every option set and never sees a prompt:
php artisan report:generate \
--period=2026-05 --tier=gold \
--region=eu-west-1 --no-interaction
If CI forgets a flag, isInteractive() is false, and the command fails loudly with Missing --tier in non-interactive mode instead of hanging until the pipeline times out. Loud failure beats a silent stall.
If you want Laravel to prompt automatically for missing arguments, implement PromptsForMissingInput on the command. It's a good default for simple cases. The manual approach above is what you reach for once options carry validation rules and CI needs a hard failure path.
The shape that keeps paying off
Look at what the command turned into. It reads options, prompts for gaps when a human is present, validates each answer, wraps the slow part in a spinner, and calls one use case. The use case takes a ReportCriteria and returns a Report. It has no idea whether the criteria came from a menu, a flag, or a test.
That's the point. The friendly CLI is an adapter. The report logic is the core. You can add an HTTP endpoint or a queue job tomorrow, each building the same ReportCriteria its own way, and the report logic doesn't change a line.
The console is one of the easiest places to smear domain logic across framework plumbing, because a command feels like a script. Keeping the interaction (prompts, spinners, flags) at the edge and the actual work in a use case that takes a plain object is exactly the separation Decoupled PHP is about: the CLI is just another way in, and the domain outlives whichever front door you built this week.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)