laravel/prompts
Laravel Prompts is a PHP package for adding beautiful, user-friendly forms to command-line apps, with browser-like features including placeholder text and validation. Perfect for Artisan console commands or any CLI PHP project.
Install / confirm availability
In a Laravel 10+ app the package is already a transitive dependency of laravel/framework, so there is nothing to install — check with:
composer show laravel/prompts
For non-Laravel CLI projects (it is framework-agnostic, only PHP 8.x + Symfony console-ish runtime):
composer require laravel/prompts
The global helper functions (text(), select(), progress(), …) are registered through Composer's files autoload, so they are available immediately without any service provider.
Where to look first
laravel.com/docs/prompts) — this is the canonical API reference; the repo README is intentionally minimal.src/helpers.php — one function per prompt type, with the full parameter list (this is the real "cheat sheet").src/Prompt.php — the base class: fallbacks (fallbackWhen, fallbackUsing), interactivity (interactive()), output (setOutput()), and the testing harness (fake(), assertPrompted(), assertNothingPrompted(), assertSettled()).src/Concerns/ — CanValidate, CanTransform, HasHints, InteractsWithIO, Scrolling etc. Most "why does it behave like that?" questions are answered here.changelog/ — useful because the package is still 0.x; APIs are added often (number() in 0.3.11, grid() in 0.3.10, conditional form steps in 0.3.1).First use case
Take an existing php artisan command that uses $this->ask() / $this->confirm() and upgrade one question. The value is immediate: validation, placeholder, default, hint, and a rendered "answered" state for free.
use function Laravel\Prompts\text;
public function handle(): int
{
$name = text(
label: 'What is your project name?',
placeholder: 'My Awesome Project',
default: 'my-project',
hint: 'Used for the directory name and namespace.',
required: 'A project name is required.',
validate: fn (string $value) => match (true) {
strlen($value) > 60 => 'The name must be 60 characters or fewer.',
! preg_match('/^[a-z0-9-_]+$/i', $value) => 'Only letters, numbers, dashes and underscores.',
default => null,
},
transform: fn (string $value) => strtolower($value),
);
return self::SUCCESS;
}
That single call exercises the mental model for everything else: label → raw value → validate → transform → returned value.
The bread-and-butter pattern. Keep prompts in handle()/interact() (not in constructors, not in the service layer), and push the resulting values into a service/action class:
$email = text('Email address', required: true, validate: fn ($v) => filter_var($v, FILTER_VALIDATE_EMAIL) ? null : 'Invalid email.');
$password = password('Password', required: true, validate: fn ($v) => strlen($v) < 12 ? 'At least 12 characters.' : null);
$role = select('Role', ['admin', 'editor', 'viewer'], default: 'editor');
$perks = multiselect('Perks', ['parking', 'lunch', 'gym'], default: ['lunch'], required: true);
$notify = confirm('Send a welcome email?', default: true, yes: 'Yes', no: 'No', hint: 'Can be changed later.');
Use Artisan::call()-free composition: the command's job is only to collect and validate input; everything after is plain PHP.
select — treat the array key as your domain value$status = select(
label: 'Status',
options: [
'draft' => 'Draft',
'published' => 'Published',
'archived' => 'Archived',
],
default: 'draft',
);
// $status === 'draft' (the KEY, not the label)
This is the cleanest way to prompt for enum-ish values — map the key in the prompt, never post-process the label string.
form() API for multi-step wizardsWhen you have 4+ related questions (installers, make:* generators, setup wizards), use form() instead of a wall of independent prompts. Steps are named, responses come back as an associative array, and the whole thing renders as one coherent flow:
use function Laravel\Prompts\form;
$responses = form()
->text('Application name', name: 'name', required: true, default: 'Laravel')
->select('Database', ['mysql', 'pgsql', 'sqlite'], name: 'driver', default: 'sqlite')
->when(
fn (array $responses) => $responses['driver'] !== 'sqlite',
fn ($form) => $form->text('Host', name: 'host', default: '127.0.0.1')
->text('Database name', name: 'database'),
)
->confirm('Run migrations now?', name: 'migrate', default: true)
->submit();
$responses['name']; // string
$responses['migrate']; // bool
Tips that pay off in practice:
name: — the returned array is only as useful as its keys, and step names are passed through since v0.3.5.submit(). If a mid-form step throws, the user's earlier answers are lost, which is the correct behaviour to keep the flow re-runnable.spin() and progress()$count = spin(
callback: fn () => User::query()->update(['active' => true]),
message: 'Activating users…',
);
$progress = progress(label: 'Importing rows', steps: $rows->count());
$progress->start();
foreach ($rows as $row) {
$progress->advance();
}
$progress->finish();
spin() returns whatever the callback returns and propagates exceptions (the spinner is torn down on failure — fixed back in 0.1.8).progress() renders in place; always call finish() so the bar doesn't bleed into subsequent output. Re-rendering during the callback is supported (0.1.24), so you can call $progress->advance() from inside a callback-driven loop.This is the pattern most teams forget, then get bitten in CI:
use Laravel\Prompts\Prompt;
Prompt::fallbackWhen(
fn () => ! $this->input->isInteractive() || app()->runningUnitTests()
);
Prompt::fallbackUsing(
fn ($prompt) => $this->components->ask($prompt->label) ?? $prompt->default
);
--no-interaction (and --quiet, plus non-TTY stdin) can be handled by returning each prompt's default.default: on prompts in commands that might run in CI/CD, containers, or schedulers. "No interactive input" + "no default" is the single most common production failure mode for this package.fallbackWhen() over sprinkling if ($this->option('no-interaction')) branches through the command.Two complementary strategies:
A. expectsQuestion — the lowest-friction approach, and it works with prompts because they read from the console input stream:
$this->artisan('user:create')
->expectsQuestion('Email address', '[email protected]')
->expectsQuestion('Password', 'secret-but-long-enough')
->expectsQuestion('Role', 'admin') // select: pass the KEY
->expectsConfirmation('Send a welcome email?', 'yes')
->assertSuccessful();
Caveat: the string you pass must match the rendered label, so keep labels static and free of dynamic values (or assert with a substring-friendly Prompt::fake() approach instead).
B. Prompt::fake() — purpose-built, supports output assertions and introspection:
use Laravel\Prompts\Prompt;
Prompt::fake(['Taylor', '[email protected]', 'admin']);
$this->artisan('user:create')->assertSuccessful();
Prompt::assertPrompted(fn ($prompt) => str_contains($prompt->label, 'Email'));
Prompt::assertOutputContains('User created.');
Prompt::assertSettled(); // ensures nothing was left hanging
false in the fake response array to simulate Ctrl+C / cancellation.Prompt::fake() also lets you assert against prompts that never rendered with assertNothingPrompted() — great for "should skip the wizard entirely when --force is passed".form(), fake the responses in step order and assert the resulting side effects, not the individual prompts.note, table, grid, alert, clear)These are the "polish" layer that makes a hand-rolled CLI feel like an official Artisan command:
note('The build cache was cleared.', 'Cache');
info('Using the local configuration file.');
warning('This will delete 3 records.');
error('Deployment failed.');
alert('Are you sure you want to continue?', 'Destructive operation');
table(
headers: ['Name', 'Status', 'Last run'],
rows: $jobs->map(fn ($j) => [$j->name, $j->status, $j->last_run_at->diffForHumans()])->all(),
);
clear() (v0.2.0) is handy between wizard phases when you don't want the user scrolling back through stale questions.
If you need a new widget, extend Laravel\Prompts\Prompt rather than bending text():
class ColorPrompt extends Prompt
{
public function value(): string { /* … */ }
protected function display(): void { /* … */ }
public function prompt(): mixed { /* … */ }
}
Then optionally wrap it in a helper function guarded by function_exists() (the package itself had to add that guard in 0.1.22 — do the same to avoid collisions).
Interactivity & terminal requirements
docker run -it; without it you get read failures or hangs. Under CI, stdin is not a TTY and non-interactive mode kicks in.stty and tput on Unix; minimal images (Alpine, distroless, some CI runners) may not have them. There's a fallback implementation, but the reliable fix is Prompt::fallbackWhen(fn () => ! Terminal::supports... ) or simply checking $this->input->isInteractive().COLUMNS drives wrapping/truncation. If output looks misaligned in a piped/recorded environment, set COLUMNS=80 explicitly.Validation quirks
validate must return null for valid and a string message for invalid. Returning false/true will not behave as you expect.validate aborts the prompt entirely (exception bubbles out of the command) — useful for "cannot continue" conditions, dangerous if you meant "re-ask".required accepts a bool or a custom message string. For select/search the custom "required" message has been supported since 0.1.9 and is much friendlier than the default.transform runs after validation, so your validator sees the raw typed value. Normalise in transform (lowercasing, trimming) but validate the input as the user typed it — otherwise error messages become confusing.Return values
select() / multiselect() / search() / multisearch() return keys, not labels. With a list-style array (['Red', 'Green']) you get integers 0/1, which is nearly always a bug in your code, not the package's.multiselect() returns an array of keys — sort/normalise it yourself if key order matters downstream.confirm() returns bool; it respects default: true in non-interactive mode.number() (added in 0.3.11) returns a numeric type — do not assume string, unlike text().Non-interactive mode
default is supplied, you'll get an error rather than a silent hang — but it fails at the point of prompting, which can be deep inside a deployment. Audit all prompts in commands that run from cron/CI and give each one a default, or register a global fallbackUsing().fallbackUsing() handler that itself prompts via $this->components->ask() will still block in CI. Guard the fallback too.Prompt::interactive(false) is a blunt global switch — handy in tests, risky in application code because it's static and leaks across the process.Testing pitfalls
Prompt::fake() before invoking the command. Faking after the first prompt has rendered gives you the real prompt.expectsQuestion() matching is literal against the rendered label; hints, placeholders and defaults are rendered around it but are not part of what you match. Keep labels short and constant.Prompt::assertSettled() and assertNothingPrompted() catch leftover/unexpected prompts.spin()/progress(): fake them like any other prompt; asserting on spinner frames is brittle, assert on the callback's effects instead.Output & rendering
$this->components->info() (Artisan's own components) with Prompts output can produce out-of-order lines because Prompts writes directly to the output in place. Prefer one system per command, or Prompt::setOutput($this->output) so both share the same buffered output.clear() deliberately. It clears the terminal, not Laravel's output buffer — it will also erase anything your command printed before it (including exception traces you were about to show).Version & dependency notes
number, grid, conditional form steps) land in patch/minor releases.text() or select() in the global namespace yourself, the Composer files autoload will collide. The package guards with function_exists(), but your own code should not add new global functions.composer require it in a Laravel app just to bump it — constrain via the framework or add an explicit requirement matching the framework's constraint, otherwise you can force a conflict on the next laravel/framework upgrade.Overall day-to-day verdict
For Laravel console work this is close to a drop-in quality upgrade: the API is small, typed, and predictable, and the fallback/testing hooks are the parts that separate a demo command from something you can ship in CI. The learning investment is roughly one afternoon; the recurring cost is remembering to set default: everywhere and to assert on keys rather than labels.
How can I help you explore Laravel packages today?