Cookbook

Writing Console Commands

Build your own Glueful CLI commands on Symfony Console — the BaseCommand base class, DI-enabled services, registration, and authoring best practices.

This guide covers how to author your own console commands in Glueful, built on Symfony Console with dependency-injection integration.

Overview

Glueful's console is a Symfony Console application wired to the framework's DI container and ApplicationContext.

Key Features

  • Symfony Console Integration: commands use #[AsCommand], configure(), and execute()
  • Dependency Injection: commands resolve services from the container
  • Styling helpers: SymfonyStyle-backed success(), error(), warning(), info() and friends
  • Interactive helpers: confirmations, choices, prompts with validation, auto-completion, progress bars, and spinners
  • Production Safety: isProduction(), confirmProduction(), confirmDestructive()

Console Architecture

  1. Application (Glueful\Console\Application): the console application, created by the glueful entry script
  2. BaseCommand (Glueful\Console\BaseCommand): base class with container, context, and output helpers
  3. Discovery: core commands are discovered from the framework's Console/Commands directory; app and extension commands are registered by service providers
  4. Command manifest: in production the discovered core command list is cached and self-heals when it goes stale

Console Architecture

Application Class

You don't construct the console yourself. The php glueful entry script boots the framework and hands its container to the console:

use Glueful\Framework;
use Glueful\Console\Application as ConsoleApplication;

$framework = Framework::create($basePath);
$app = $framework->boot();

$console = new ConsoleApplication($app->getContainer());
exit($console->run());

When two packages register the same command name, the last one wins and the console logs which class replaced which.

BaseCommand Class

Commands extend BaseCommand:

use Glueful\Console\BaseCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class MyCommand extends BaseCommand
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $this->info('Starting operation...');
        $this->success('Operation completed!');
        return self::SUCCESS;
    }
}

Output Methods:

  • $this->success($message) - Green success message
  • $this->error($message) - Red error message
  • $this->warning($message) - Yellow warning message
  • $this->info($message) - Info message
  • $this->note($message) - Highlighted note
  • $this->tip($message) - "Tip:" prefixed message
  • $this->line($message) - Plain text line
  • $this->jsonForDisplay($value) - JSON-encode a value for display

Interactive Methods:

  • $this->confirm($question, $default) - Yes/no confirmation
  • $this->ask($question, $default) - Text input with optional default
  • $this->secret($question) - Hidden input for passwords
  • $this->choice($question, $choices, $default) - Single choice
  • $this->prompt($question, $default, $validator) - Text input with a validation callback
  • $this->promptRequired($question, $default) - Re-asks until the answer is non-empty
  • $this->multiChoice($question, $choices, $defaults) - Multiple selection
  • $this->suggest($question, $suggestions, $default) - Input with auto-completion
  • $this->isInteractive() - Whether input is interactive (prompts fall back to defaults otherwise)

Progress Methods:

  • $this->table($headers, $rows) - Formatted table
  • $this->createProgressBar($max) - Symfony progress bar
  • $this->progressBar($steps, $callback) - Progress bar passed to a callback
  • $this->createEnhancedProgressBar($max) - Glueful progress bar with message formats
  • $this->withProgress($items, $callback, $message) - Iterate items with a progress bar
  • $this->createSpinner($message, $style) - Spinner for indeterminate work
  • $this->withSpinner($callback, $message) - Run a callback behind a spinner, returns its result
  • $this->withSpinnerSuccess($callback, $message, $successMessage) - Same, then prints a success line

Utility Methods:

  • $this->getService($serviceId) - Resolve a service from the container
  • $this->getServiceDynamic($serviceId) - Resolve a service by a runtime id
  • $this->getContainer() - The DI container
  • $this->getContext() - The ApplicationContext (pass it to config(), db(), app(), …)
  • $this->isProduction() - Whether APP_ENV is production
  • $this->confirmProduction($operation) - Extra confirmation in production
  • $this->confirmDestructive($message) - Warning + confirmation; in non-interactive mode returns the --force option (so define one)

Custom Commands

Creating Custom Commands

<?php

namespace App\Console\Commands;

use App\Services\MyService;
use Glueful\Console\BaseCommand;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
    name: 'my:command',
    description: 'Custom command description'
)]
class MyCommand extends BaseCommand
{
    protected function configure(): void
    {
        $this->setHelp('Detailed help text for the command')
             ->addArgument('name', InputArgument::REQUIRED, 'Name argument')
             ->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip the production confirmation');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $name = $input->getArgument('name');
        $force = (bool) $input->getOption('force');

        if (!$force && !$this->confirmProduction('execute custom operation')) {
            return self::FAILURE;
        }

        try {
            $this->info("Starting operation for: {$name}");

            $myService = $this->getService(MyService::class);

            $this->withProgress(range(1, 10), function (int $i) use ($myService): void {
                $myService->doSomething($i);
            }, 'Processing');

            $this->success('Operation completed successfully!');
            return self::SUCCESS;
        } catch (\Exception $e) {
            $this->error('Operation failed: ' . $e->getMessage());
            return self::FAILURE;
        }
    }
}

Registering Custom Commands

App and extension commands are registered from a service provider's boot(). For an app, use a provider listed in config/serviceproviders.php (for example App\Providers\AppServiceProvider); for an extension, its own provider. Both are Glueful\Extensions\ServiceProvider subclasses:

use Glueful\Bootstrap\ApplicationContext;
use Glueful\Extensions\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function boot(ApplicationContext $context): void
    {
        // Option A: discover every #[AsCommand] class in a directory (recursive, skips abstract classes)
        $this->discoverCommands('App\\Console\\Commands', base_path($context, 'app/Console/Commands'));

        // Option B: list classes explicitly
        $this->commands([
            \App\Console\Commands\MyCommand::class,
        ]);
    }
}

Both helpers do nothing outside the CLI. Commands are taken from the container when it has a definition for the class (declare it in services() if its constructor needs injected dependencies); otherwise a BaseCommand subclass is constructed with the container and context.

Code that already holds the console application can also call $console->registerCommandClass(MyCommand::class), which resolves the class from the container and skips names that are already registered.

Command Templates

There's no make:command / generate:command. Use the scaffold commands for the classes a command usually works with:

php glueful scaffold:job SendDigestEmail
php glueful scaffold:middleware EnsureAdmin
php glueful scaffold:test UserControllerTest
php glueful scaffold:controller ReportController   # writes app/Controllers (App\Controllers)

scaffold:controller ends by printing the services() line to add to your app provider so the router can resolve the new controller. A command class has no scaffold; write it by hand as shown above and register it with discoverCommands() or commands().

Scheduled work doesn't need a custom command either: define it in config/schedule.php and run php glueful queue:scheduler (run, work, list, status, health). See Scheduling.

Best Practices

Command Design

  1. Use Descriptive Names: follow the group:action pattern
  2. Provide Good Help: include descriptions and examples
  3. Handle Errors Gracefully: catch exceptions and return FAILURE with a clear message
  4. Production Safety: confirm destructive operations, and support --force for non-interactive runs
  5. Progress Feedback: use withProgress() or a spinner for long-running work

New core commands appear in production automatically after a framework upgrade: the cached command manifest is stamped with the framework version and rediscovered when stale. php glueful commands:cache prebuilds it (--status shows it, --clear removes it).

Input Validation

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $email = $input->getOption('email');
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $this->error('Invalid email address provided');
        return self::INVALID;
    }

    $file = $input->getArgument('file');
    if (!file_exists($file)) {
        $this->error("File not found: {$file}");
        return self::FAILURE;
    }

    return self::SUCCESS;
}

Interactive Commands

protected function execute(InputInterface $input, OutputInterface $output): int
{
    if (!$this->confirm('Do you want to continue?', false)) {
        $this->info('Operation cancelled');
        return self::SUCCESS;
    }

    $action = $this->choice('What would you like to do?', ['create', 'update', 'delete'], 'create');

    // Re-asks until a non-empty value is given
    $name = $this->promptRequired('Enter name');

    // Validated input: the validator throws to reject the answer
    $port = $this->prompt('Port', '8080', function (?string $value): string {
        if (!ctype_digit((string) $value)) {
            throw new \RuntimeException('Port must be a number');
        }
        return (string) $value;
    });

    return self::SUCCESS;
}

Output Formatting

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $this->table(
        ['ID', 'Name', 'Status'],
        [
            [1, 'John Doe', 'Active'],
            [2, 'Jane Smith', 'Inactive'],
        ]
    );

    // Progress over a collection
    $this->withProgress(range(1, 100), function (int $item): void {
        usleep(100_000);
    });

    // Indeterminate work
    $count = $this->withSpinner(fn () => $this->countRecords(), 'Counting records...');

    return self::SUCCESS;
}

Error Handling

use Glueful\Validation\ValidationException;

protected function execute(InputInterface $input, OutputInterface $output): int
{
    try {
        $result = $this->performOperation();

        if (!$result) {
            $this->warning('Operation completed with warnings');
            return self::SUCCESS;
        }

        $this->success('Operation completed successfully');
        return self::SUCCESS;
    } catch (ValidationException $e) {
        $this->error('Validation failed: ' . $e->getMessage());
        return self::INVALID;
    } catch (\Exception $e) {
        $this->error('Unexpected error: ' . $e->getMessage());

        if ($output->isVerbose()) {
            $this->line($e->getTraceAsString());
        }

        return self::FAILURE;
    }
}

Service Integration

Resolve services in execute() (or a constructor that calls parent::__construct() first). The database connection is the Glueful\Database\Connection service:

use Glueful\Database\Connection;

class MyCommand extends BaseCommand
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $db = $this->getService(Connection::class);   // or db($this->getContext())
        $myService = $this->getService(MyService::class);

        $users = $db->table('users')->where('status', 'active')->get();
        $myService->processUsers($users);

        return self::SUCCESS;
    }
}

Configuration and Environment

protected function execute(InputInterface $input, OutputInterface $output): int
{
    if ($this->isProduction() && !$input->getOption('force')) {
        if (!$this->confirmProduction('perform this operation')) {
            return self::FAILURE;
        }
    }

    // Helpers take the ApplicationContext
    $timeout = config($this->getContext(), 'app.timeout', 30);
    $debug = config($this->getContext(), 'app.debug', false);

    return self::SUCCESS;
}

The console gives you a consistent base for app and extension tooling: styled output, safe prompts, progress feedback, and full access to the container.