zenstruck/console-extra
Modular helpers to reduce boilerplate in Symfony console commands. Build invokable commands with auto-injected IO, arguments/options via attributes, and handy traits to run other commands or processes. Designed to mix and match features or power a shared base command.
Installation:
composer require zenstruck/console-extra
First Use Case:
Create a simple command extending InvokableServiceCommand with auto-injected IO and a service:
use Zenstruck\Console\InvokableServiceCommand;
use Zenstruck\Console\IO;
use App\Services\UserService;
#[AsCommand('app:greet')]
class GreetCommand extends InvokableServiceCommand
{
public function __invoke(IO $io, UserService $userService): void
{
$io->success($userService->getGreeting());
}
}
Run it:
php artisan app:greet
Key Files to Explore:
app/Console/Commands/ (for command classes)config/services.php (for service bindings)app/Providers/AppServiceProvider.php (for registering traits like RunsCommands)InvokableServiceCommand for all app commands to leverage auto-wiring and IO.
abstract class AppCommand extends InvokableServiceCommand
{
use RunsCommands, RunsProcesses;
}
#[Argument] and #[Option] on __invoke() parameters.
public function __invoke(
#[Argument] string $name,
#[Option] bool $force = false
): void { ... }
__invoke().
public function __invoke(IO $io, UserRepository $users, LoggerInterface $logger): void { ... }
public function __invoke(
#[Autowire('%env(APP_NAME)%')] string $appName,
#[TaggedIterator('app.event_listener')] iterable $listeners
): void { ... }
RunsCommands trait to chain commands.
public function __invoke(): void
{
$this->runCommand('migrate:fresh');
$this->runCommand('db:seed', ['--class' => 'UserSeeder']);
}
RunsProcesses for shell scripts.
public function __invoke(): void
{
$this->runProcess(['php', 'artisan', 'queue:work', '--once']);
}
IO for user prompts and validation.
public function __invoke(IO $io): void
{
$confirm = $io->confirm('Proceed?', false);
if (!$confirm) return;
$this->runCommand('deploy:production');
}
use Zenstruck\Console\CommandRunner;
public function deploy(Request $request)
{
CommandRunner::from(app('console'), 'deploy:production')->run();
return response()->json(['status' => 'deployed']);
}
Service Provider Setup:
Register the CommandSummarySubscriber in AppServiceProvider:
public function boot(): void
{
$this->app->make('command.bus')->getDispatcher()->addSubscriber(
new \Zenstruck\Console\EventListener\CommandSummarySubscriber()
);
}
Command Registration:
Use #[AsCommand] for auto-registration (Laravel 10+):
#[AsCommand(name: 'app:custom', description: 'Custom app command')]
class CustomCommand extends InvokableServiceCommand { ... }
Artisan Integration:
Extend Laravel’s Command class for hybrid usage:
use Illuminate\Console\Command;
use Zenstruck\Console\IO;
class HybridCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new IO($input, $output);
// Use $io for styled output
return 0;
}
}
runCommand() with --batch for non-interactive runs.null for silent execution:
$this->runCommand('queue:work --once', [], null, new \Symfony\Component\Console\Output\NullOutput());
IO:
$io = $this->createMock(IO::class);
$io->method('success')->willReturnCallback(fn($msg) => $this->assertEquals('Expected', $msg));
$command = new MyCommand();
$command->setIo($io);
$command->__invoke($io);
$this->assertSame(0, CommandRunner::for($command)->run('--arg=value'));
Trait Overuse:
RunsCommands or RunsProcesses to every command bloats memory.AppCommand) with shared traits.
abstract class AppCommand extends InvokableServiceCommand
{
use RunsCommands, RunsProcesses;
}
Attribute Conflicts:
#[Argument] on class level and parameter level causes conflicts.public function __invoke(
#[Argument] string $name, // Defines argument here
): void { ... }
Process Timeouts:
runProcess() throws RuntimeException on failure, which may break command flow.Process::mustRun() for explicit control.
try {
$this->runProcess(['long-running-script']);
} catch (\RuntimeException $e) {
$this->io()->error('Process failed: ' . $e->getMessage());
}
IO Injection:
IO in __invoke() loses styled output methods.IO $io as the first parameter for consistency.Command Summary:
if (app()->environment('local')) {
$dispatcher->addSubscriber(new CommandSummarySubscriber());
}
Command Arguments:
var_dump($this->input()) to inspect raw input in __invoke().Process Output:
-v to see hidden process output:
php artisan my:command -v
Dependency Injection:
php artisan container:debug
Attribute Validation:
vendor/bin/phpstan analyse --level=5 app/Console/Commands
Custom IO Methods:
IO to add app-specific helpers:
class AppIO extends IO
{
public function tableWithHeader(array $rows, string $header): void
{
$this->section($header);
$this->table($rows);
}
}
Command Decorators:
class LoggingCommandDecorator
{
public function __invoke(InvokableServiceCommand $command, IO $io)
{
$start = microtime(true);
$result = $command($io);
$io->text(sprintf('Executed in %.2fms', (microtime(true) - $start) * 1000));
return $result;
}
}
Dynamic Suggestions:
#[Option(suggestions: 'getUserRoles')]
public function __invoke(IO $io, array $roles): void { ... }
private function getUserRoles(): array
{
return $this->userRepository->getAvailableRoles();
}
Command Metadata:
#[AsCommand] to centralize command metadata (e.g., categories):
How can I help you explore Laravel packages today?