Building CLI Tools with PHP

Reverend Philip Dec 13, 2025 4 min read

Create powerful command-line applications using PHP and Laravel. Master Artisan commands, Symfony Console, and interactive prompts.

Command-line tools remain essential for automation, development workflows, and system administration. PHP, despite its web focus, is excellent for building CLI applications. This guide covers building powerful command-line tools using Laravel Artisan and Symfony Console.

Why PHP for CLI Tools?

  • Familiar language: Use your existing PHP skills
  • Rich ecosystem: Composer packages, Laravel integrations
  • Cross-platform: Runs anywhere PHP runs
  • Easy distribution: Single file or Phar archives

Laravel Artisan Commands

Creating a Command

Laravel provides an artisan generator to scaffold new commands quickly.

php artisan make:command SendWeeklyReport

The generated command class gives you a clean structure for defining arguments, options, and the main logic. Here's a complete example that sends weekly reports with dry-run support.

// app/Console/Commands/SendWeeklyReport.php
namespace App\Console\Commands;

use Illuminate\Console\Command;

class SendWeeklyReport extends Command
{
    protected $signature = 'report:weekly
        {--recipient= : Email recipient}
        {--dry-run : Preview without sending}';

    protected $description = 'Send weekly analytics report';

    public function handle(): int
    {
        $recipient = $this->option('recipient') ?? config('mail.admin');

        if ($this->option('dry-run')) {
            $this->info("Would send report to: {$recipient}");
            return Command::SUCCESS;
        }

        $this->info('Generating report...');
        // Generate and send report

        $this->info('Report sent successfully!');
        return Command::SUCCESS;
    }
}

The $signature property uses Laravel's expressive syntax to define the command name and its inputs. Options with = accept values, while boolean flags like --dry-run don't.

Command Signatures

Laravel's signature syntax handles common CLI patterns elegantly. You can define required arguments, optional ones, defaults, and multiple value options all in a single string.

// Required argument
protected $signature = 'user:create {name}';

// Optional argument
protected $signature = 'user:create {name?}';

// Argument with default
protected $signature = 'user:create {name=Guest}';

// Options
protected $signature = 'user:create
    {name : The user name}
    {--admin : Create as admin}
    {--role=user : User role}
    {--R|roles=* : Multiple roles}';

// Usage
$name = $this->argument('name');
$isAdmin = $this->option('admin');
$role = $this->option('role');
$roles = $this->option('roles');

The text after colons provides descriptions shown in help output. The * modifier allows users to specify an option multiple times.

Interactive Input

When you need user input during execution, Laravel provides several methods for different scenarios. These make your commands more user-friendly and flexible.

public function handle(): int
{
    // Simple question
    $name = $this->ask('What is your name?');

    // With default
    $email = $this->ask('Email address?', 'user@example.com');

    // Hidden input (passwords)
    $password = $this->secret('Enter password');

    // Confirmation
    if ($this->confirm('Do you want to continue?', true)) {
        // Proceed
    }

    // Choice selection
    $role = $this->choice(
        'Select role',
        ['admin', 'editor', 'user'],
        2 // Default index
    );

    // Anticipate (with autocomplete)
    $city = $this->anticipate(
        'Which city?',
        ['New York', 'Los Angeles', 'Chicago']
    );

    return Command::SUCCESS;
}

The secret method hides input as users type, essential for passwords. The anticipate method provides tab-completion from a list of suggestions.

Output Formatting

Clear, well-formatted output makes commands easier to use. Laravel provides semantic output methods and built-in support for tables and progress bars.

public function handle(): int
{
    // Basic output
    $this->info('Information message');
    $this->comment('Comment');
    $this->question('Question style');
    $this->error('Error message');
    $this->warn('Warning message');
    $this->newLine(2);

    // Tables
    $this->table(
        ['Name', 'Email', 'Role'],
        [
            ['John', 'john@example.com', 'Admin'],
            ['Jane', 'jane@example.com', 'User'],
        ]
    );

    // Progress bar
    $users = User::all();
    $bar = $this->output->createProgressBar(count($users));
    $bar->start();

    foreach ($users as $user) {
        $this->processUser($user);
        $bar->advance();
    }

    $bar->finish();
    $this->newLine();

    return Command::SUCCESS;
}

Each output method uses distinct colors: info is green, error is red, comment is yellow. Progress bars are essential for long-running operations so users know something is happening.

Calling Other Commands

Sometimes you need to compose commands from other commands or ensure certain cleanup tasks run. Laravel makes this straightforward.

public function handle(): int
{
    // Call another command
    $this->call('cache:clear');

    // With arguments
    $this->call('user:create', [
        'name' => 'John',
        '--admin' => true,
    ]);

    // Silently
    $this->callSilently('cache:clear');

    return Command::SUCCESS;
}

The callSilently method suppresses output, useful when you need to run something but don't want it cluttering your command's output.

Symfony Console Standalone

For non-Laravel applications, Symfony Console provides the same powerful features. You can build complete CLI tools with just a few files.

// bin/myapp
#!/usr/bin/env php
<?php

require __DIR__ . '/../vendor/autoload.php';

use Symfony\Component\Console\Application;
use App\Command\GreetCommand;

$app = new Application('MyApp', '1.0.0');
$app->add(new GreetCommand());
$app->run();

The Application class manages command registration and handles routing user input to the appropriate command.

// src/Command/GreetCommand.php
namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

class GreetCommand extends Command
{
    protected static $defaultName = 'greet';
    protected static $defaultDescription = 'Greet someone';

    protected function configure(): void
    {
        $this
            ->addArgument('name', InputArgument::REQUIRED, 'Who to greet')
            ->addOption('yell', 'y', InputOption::VALUE_NONE, 'Yell the greeting');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $name = $input->getArgument('name');

        $greeting = "Hello, {$name}!";

        if ($input->getOption('yell')) {
            $greeting = strtoupper($greeting);
        }

        $io->success($greeting);

        return Command::SUCCESS;
    }
}

SymfonyStyle wraps input and output to provide a consistent, attractive interface. The configure method defines arguments and options using method chaining.

Laravel Prompts

Laravel 10+ includes beautiful interactive prompts that elevate the CLI experience with modern, keyboard-navigable interfaces.

use function Laravel\Prompts\text;
use function Laravel\Prompts\password;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\select;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\search;
use function Laravel\Prompts\spin;
use function Laravel\Prompts\progress;

public function handle(): int
{
    // Text input with validation
    $name = text(
        label: 'What is your name?',
        placeholder: 'John Doe',
        required: true,
        validate: fn ($value) => strlen($value) < 2
            ? 'Name must be at least 2 characters.'
            : null
    );

    // Password
    $password = password(
        label: 'Enter password',
        validate: fn ($value) => strlen($value) < 8
            ? 'Password must be at least 8 characters.'
            : null
    );

    // Select
    $role = select(
        label: 'Select a role',
        options: ['admin', 'editor', 'user'],
        default: 'user'
    );

    // Multi-select
    $permissions = multiselect(
        label: 'Select permissions',
        options: ['read', 'write', 'delete'],
        default: ['read']
    );

    // Search with callback
    $userId = search(
        label: 'Search for a user',
        options: fn ($search) => User::where('name', 'like', "%{$search}%")
            ->pluck('name', 'id')
            ->toArray()
    );

    // Spinner for long operations
    $result = spin(
        fn () => $this->longOperation(),
        'Processing...'
    );

    // Progress bar
    $users = User::all();
    progress(
        label: 'Processing users',
        steps: $users,
        callback: fn ($user) => $this->processUser($user)
    );

    return Command::SUCCESS;
}

The search prompt with a callback is particularly powerful for large datasets. It only queries the database as users type, keeping the interface responsive. The spin function displays an animated spinner during operations where you can't show meaningful progress.

Error Handling

Robust error handling separates professional tools from scripts. Return appropriate exit codes and provide helpful error messages.

public function handle(): int
{
    try {
        $this->processData();
        return Command::SUCCESS;
    } catch (FileNotFoundException $e) {
        $this->error("File not found: {$e->getMessage()}");
        return Command::FAILURE;
    } catch (\Exception $e) {
        $this->error("Unexpected error: {$e->getMessage()}");

        if ($this->option('verbose')) {
            $this->error($e->getTraceAsString());
        }

        return Command::FAILURE;
    }
}

Checking for the verbose flag before printing stack traces keeps output clean for normal use while providing debugging information when needed.

Testing Commands

Laravel provides excellent testing support for console commands. You can assert on output, simulate interactive prompts, and verify side effects.

// tests/Feature/Console/SendReportCommandTest.php
use Illuminate\Support\Facades\Mail;

public function test_sends_report_to_recipient(): void
{
    Mail::fake();

    $this->artisan('report:weekly', ['--recipient' => 'test@example.com'])
        ->expectsOutput('Generating report...')
        ->expectsOutput('Report sent successfully!')
        ->assertSuccessful();

    Mail::assertSent(WeeklyReport::class, fn ($mail) =>
        $mail->hasTo('test@example.com')
    );
}

public function test_dry_run_does_not_send(): void
{
    Mail::fake();

    $this->artisan('report:weekly', ['--dry-run' => true])
        ->assertSuccessful();

    Mail::assertNotSent(WeeklyReport::class);
}

public function test_interactive_prompts(): void
{
    $this->artisan('user:create')
        ->expectsQuestion('What is your name?', 'John')
        ->expectsQuestion('Email address?', 'john@example.com')
        ->expectsConfirmation('Create as admin?', 'yes')
        ->assertSuccessful();

    $this->assertDatabaseHas('users', [
        'name' => 'John',
        'email' => 'john@example.com',
    ]);
}

The fluent assertion API makes tests readable. Use expectsQuestion to provide answers to interactive prompts, simulating user input.

Distributing CLI Tools

Phar Archives

Phar (PHP Archive) packages your entire application into a single executable file. Box is the standard tool for building Phar archives.

// box.json
{
    "main": "bin/myapp",
    "output": "build/myapp.phar",
    "directories": ["src"],
    "finder": [
        {
            "name": "*.php",
            "in": "vendor"
        }
    ],
    "compression": "GZ"
}

The configuration specifies your entry point, output location, and which files to include. Compression reduces the final file size.

# Build phar
box compile

# Run
php build/myapp.phar greet World

Global Composer Package

For tools you want to install system-wide, publish to Packagist and let users install via Composer.

// composer.json
{
    "name": "mycompany/myapp",
    "bin": ["bin/myapp"],
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}

The bin key tells Composer which files are executables that should be linked into the user's PATH.

# Install globally
composer global require mycompany/myapp

# Run from anywhere
myapp greet World

Users need to add Composer's global bin directory to their PATH for this to work seamlessly.

Scheduling Commands

Laravel's scheduler lets you define when commands run right in your code, replacing complex cron configurations.

// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    $schedule->command('report:weekly')
        ->weekly()
        ->mondays()
        ->at('08:00')
        ->emailOutputOnFailure('admin@example.com');

    $schedule->command('cache:clear')
        ->daily()
        ->runInBackground();

    $schedule->command('backup:run')
        ->dailyAt('02:00')
        ->withoutOverlapping()
        ->onOneServer();
}

The withoutOverlapping method prevents a command from starting if a previous run is still executing. The onOneServer method ensures only one server runs the command in a multi-server deployment.

Best Practices

Exit Codes

Use meaningful exit codes so scripts can react appropriately to failures. Laravel provides constants for common cases.

// Use constants for clarity
return Command::SUCCESS;  // 0
return Command::FAILURE;  // 1
return Command::INVALID;  // 2

// Custom exit codes for specific errors
const EXIT_FILE_NOT_FOUND = 10;
const EXIT_PERMISSION_DENIED = 11;

Exit code 0 means success. Any non-zero value indicates failure. Custom codes let calling scripts distinguish between different error conditions.

Signal Handling

Long-running commands should handle interruption gracefully. Register signal handlers to clean up resources when users press Ctrl+C.

public function handle(): int
{
    pcntl_signal(SIGINT, function () {
        $this->info("\nGracefully shutting down...");
        $this->cleanup();
        exit(0);
    });

    while ($this->running) {
        pcntl_signal_dispatch();
        $this->processNextItem();
    }

    return Command::SUCCESS;
}

Call pcntl_signal_dispatch regularly in your loop to check for pending signals. This ensures your handler runs promptly when the user interrupts.

Logging

Log command execution for debugging and auditing, especially for automated tasks where you won't see the output directly.

public function handle(): int
{
    $this->info('Starting import...');
    Log::channel('commands')->info('Import started', [
        'user' => get_current_user(),
        'arguments' => $this->arguments(),
    ]);

    // Process...

    return Command::SUCCESS;
}

Include context like which user ran the command and what arguments they provided. This helps when investigating issues with scheduled tasks.

Conclusion

PHP is a capable platform for building CLI tools. Laravel Artisan provides a powerful foundation with elegant syntax for arguments, options, and interactive prompts. For standalone tools, Symfony Console offers the same features without framework dependencies. Distribute your tools as Phar archives or Composer packages for easy installation and updates.

Share this article

Related Articles

Distributed Locking Patterns

Coordinate access to shared resources across services. Implement distributed locks with Redis, ZooKeeper, and databases.

Jan 16, 2026

API Design First Development

Design APIs before implementing them. Use OpenAPI specifications, mock servers, and contract-first workflows.

Jan 15, 2026

Need help with your project?

Let's discuss how we can help you build reliable software.