CLI- and Web-compatible prompts for Laravel Artisan commands – with a flow that can continue step-by-step in the browser.
To make a command work as a flow in both CLI and Web, you only need to follow these rules:
Extend FlowCommand
use Moox\Prompts\Support\FlowCommand;
use function Moox\Prompts\text;
use function Moox\Prompts\select;
class ProjectSetupCommand extends FlowCommand
{
protected $signature = 'prompts:project-setup';
protected $description = 'Project setup wizard (CLI & Web)';
Store state as public properties
(they are automatically persisted between steps in the web flow)
public ?string $environment = null;
public ?string $projectName = null;
Define steps via promptFlowSteps() – the array order is the flow order
public function promptFlowSteps(): array
{
return [
'stepIntro',
'stepEnvironment',
'stepProjectName',
'stepSummary',
];
}
Each step is a public function stepXyz(): void – ideally one prompt per step
public function stepIntro(): void
{
$this->info('=== Project Setup ===');
}
public function stepEnvironment(): void
{
$this->environment = select(
label: 'Which environment do you want to configure?',
options: [
'local' => 'Local',
'staging' => 'Staging',
'production' => 'Production',
],
default: 'local',
);
}
public function stepProjectName(): void
{
$this->projectName = text(
label: 'What is your project name?',
placeholder: 'e.g. MyCoolApp',
validate: 'required|min:3',
required: true,
);
}
public function stepSummary(): void
{
$this->info('--- Summary ---');
$this->line('Project: '.$this->projectName);
$this->line('Environment: '.$this->environment);
}
}
Optional steps can simply be skipped with a guard at the beginning:
public array $features = [];
public function stepLoggingLevel(): void
{
if (! in_array('logging', $this->features, true)) {
return; // skip step
}
// Prompt …
}
Calling other Artisan commands – in a flow, always use $this->call() instead of Artisan::call(), so the output is also visible in the web UI:
public function stepPublishConfig(): void
{
$shouldPublish = confirm(
label: 'Publish the config now?',
default: true,
);
if (! $shouldPublish) {
return;
}
$this->call('vendor:publish', [
'--tag' => 'moox-prompts-config',
]);
}
That’s all you need in the command – no special flow methods, no custom persistence.
Everything else (CLI/Web differences, state, web UI) is handled by the package.
Once you’ve created a flow command, you can run it in both CLI and browser.
php artisan prompts:project-setup
The command behaves like a normal Laravel Artisan command – all prompts are shown in the terminal.
Note: All commands executed via the web UI are automatically logged in the database (see Command Execution Logging).
If you’re just writing commands, you don’t need to care about reflection.
To understand what happens under the hood, here’s a short overview.
Problem 1: Setting arguments & options in the web flow
Laravel stores arguments/options internally on a protected $input property of your command.
In CLI mode the Artisan kernel takes care of this.
In the web flow, we create fresh command instances – and need to set $input ourselves.
That’s what PromptFlowRunner::setCommandInput() does via reflection:
input property on your command object,ArrayInput instance.argument() and option() normally – both in CLI and in the browser.Problem 2: Remembering command state between web requests
In the web flow, your command runs across multiple HTTP requests. Without extra logic, properties like $environment, $features, $projectName would be lost between steps.
PromptFlowRunner handles this with two internal methods:
captureCommandContext($command, $state)
null) into PromptFlowState::$contextrestoreCommandContext($command, $state)
Problem 3: Initializing package tools in the web context
Many packages using Spatie\LaravelPackageTools only register publishable resources (config, views, migrations, assets, …) in CLI context.
WebCommandRunner uses reflection to access the internal package object and replay publishes(...) registrations for the web context.
Result: Commands like vendor:publish work just as well in the browser as in CLI, even though Laravel is not running in console mode there.
Important:
Reflection is only used inside the package internals, not in your flow commands.
Your commands remain normal Laravel commands – you only need to:
FlowCommand,promptFlowSteps(),step* methods (ideally one prompt per step).The package takes care of the rest (reflection, state, web flow).
Yes – technically we could avoid reflection, but it would degrade the DX:
argument()/option(), or force you to manage everything via properties/arrays. That’s less “Laravel-ish” and harder to learn.flowContextKeys()), or manage cache/DB/session yourself. That’s more boilerplate and error-prone.That’s why we intentionally keep reflection encapsulated in the package and keep your command API as simple as possible.
All commands executed via the web interface are automatically logged in the database.
You can inspect them via the Filament resource “Command Executions”.
Each execution has one of the following statuses:
running: The command is currently runningcompleted: The command finished successfullyfailed: The command failed with an errorcancelled: The command was cancelled by the user or aborted mid-flowFor each execution we store:
$environment, $projectName, $features, …)failed status – the error message and the step where it occurred (failed_at_step)cancelled status – the step where cancellation happened (cancelled_at_step)created_by)step_outputs vs contextIt’s important to understand the difference between these two fields:
step_outputs
Contains the console output of each step.
This is everything you print via $this->info(), $this->line(), $this->warn(), etc.
Example:
public function stepEnvironment(): void
{
$this->environment = select(...);
$this->info("✅ Environment: {$this->environment}");
}
will result in e.g.:
"stepEnvironment": "✅ Environment: production\n"
context
Contains the raw state of your command – all public, non-static properties from your concrete command class.
This includes values returned from text(), select(), multiselect(), confirm(), etc.
Example:
public ?bool $publishConfig = null;
public function stepPublishConfigConfirm(): void
{
$this->publishConfig = confirm(
label: 'Publish the config now?',
default: true,
);
}
will store in context something like:
{
"publishConfig": true
}
but step_outputs["stepPublishConfigConfirm"] will be an empty string, because confirm() itself doesn’t print anything.
If you want to see the user’s choice in the step output as well, you can explicitly print it:
public function stepPublishConfigConfirm(): void
{
$this->publishConfig = confirm(
label: 'Publish the config now?',
default: true,
);
$this->info('✅ Publish config: ' . ($this->publishConfig ? 'yes' : 'no'));
}
This way you have:
context.publishConfig, andstep_outputs.stepPublishConfigConfirm for the history/inspector UI.To enable logging, run:
php artisan migrate
This creates the command_executions table with all necessary fields.
The Filament resource “Command Executions” is automatically available in the Filament navigation (if enabled). There you can:
The resource also shows which step a command failed on (failed_at_step) or where it was cancelled (cancelled_at_step).
See LICENSE.md
How can I help you explore Laravel packages today?