Summary: Master advanced PHP CLI development with professional command architectures, argument parsing, interactive interfaces, and async processing. Build enterprise-grade CLI tools with modern PHP features and best practices for scalable command-line applications.
Table Of Contents
- Introduction
- Advanced CLI Architecture Patterns
- Real-World CLI Application Examples
- Related Posts
- Conclusion
Introduction
PHP's command-line interface capabilities have evolved far beyond simple scripts that echo text or manipulate files. Modern PHP CLI development embraces sophisticated architectures, interactive user interfaces, asynchronous processing, and enterprise-grade tooling that rivals traditional CLI languages like Python and Go.
Building professional CLI applications requires understanding advanced patterns, proper error handling, argument validation, progress reporting, and user experience design. Whether you're creating developer tools, system utilities, or data processing pipelines, modern PHP provides the foundation for robust, maintainable command-line applications that can handle complex workflows and scale with your needs.
Advanced CLI Architecture Patterns
Command Pattern Implementation
Create a flexible command architecture that supports multiple commands and extensibility:
// src/CLI/Command/AbstractCommand.php
<?php
namespace App\CLI\Command;
use App\CLI\IO\InputInterface;
use App\CLI\IO\OutputInterface;
abstract class AbstractCommand
{
protected string $name;
protected string $description;
protected array $arguments = [];
protected array $options = [];
protected InputInterface $input;
protected OutputInterface $output;
public function __construct(string $name, string $description = '')
{
$this->name = $name;
$this->description = $description;
$this->configure();
}
abstract protected function configure(): void;
abstract protected function execute(): int;
public function run(InputInterface $input, OutputInterface $output): int
{
$this->input = $input;
$this->output = $output;
try {
$this->validateInput();
return $this->execute();
} catch (\Throwable $e) {
$this->output->error("Error: " . $e->getMessage());
return 1;
}
}
protected function addArgument(string $name, bool $required = true, string $description = ''): self
{
$this->arguments[$name] = [
'required' => $required,
'description' => $description,
'value' => null
];
return $this;
}
protected function addOption(string $name, string $shortcut = null, $default = null, string $description = ''): self
{
$this->options[$name] = [
'shortcut' => $shortcut,
'default' => $default,
'description' => $description,
'value' => null
];
return $this;
}
protected function validateInput(): void
{
foreach ($this->arguments as $name => $config) {
$value = $this->input->getArgument($name);
if ($config['required'] && ($value === null || $value === '')) {
throw new \InvalidArgumentException("Argument '{$name}' is required");
}
$this->arguments[$name]['value'] = $value;
}
foreach ($this->options as $name => $config) {
$value = $this->input->getOption($name) ?? $config['default'];
$this->options[$name]['value'] = $value;
}
}
protected function getArgument(string $name)
{
return $this->arguments[$name]['value'] ?? null;
}
protected function getOption(string $name)
{
return $this->options[$name]['value'] ?? null;
}
public function getName(): string
{
return $this->name;
}
public function getDescription(): string
{
return $this->description;
}
public function getHelp(): string
{
$help = "Usage: php {$this->name}";
if (!empty($this->arguments)) {
foreach ($this->arguments as $name => $config) {
$help .= $config['required'] ? " <{$name}>" : " [{$name}]";
}
}
if (!empty($this->options)) {
$help .= " [options]";
}
$help .= "\n\nDescription:\n " . $this->description . "\n";
if (!empty($this->arguments)) {
$help .= "\nArguments:\n";
foreach ($this->arguments as $name => $config) {
$help .= sprintf(" %-20s %s\n",
($config['required'] ? '<' . $name . '>' : '[' . $name . ']'),
$config['description']
);
}
}
if (!empty($this->options)) {
$help .= "\nOptions:\n";
foreach ($this->options as $name => $config) {
$shortcut = $config['shortcut'] ? "-{$config['shortcut']}, " : " ";
$help .= sprintf(" %s--%-15s %s\n", $shortcut, $name, $config['description']);
}
}
return $help;
}
}
Advanced Input/Output System
Implement a sophisticated I/O system with formatting and interactive features:
// src/CLI/IO/ConsoleOutput.php
<?php
namespace App\CLI\IO;
class ConsoleOutput implements OutputInterface
{
private array $styles = [
'success' => "\033[32m", // Green
'error' => "\033[31m", // Red
'warning' => "\033[33m", // Yellow
'info' => "\033[36m", // Cyan
'comment' => "\033[37m", // Light gray
'question' => "\033[35m", // Magenta
'bold' => "\033[1m",
'dim' => "\033[2m",
'reset' => "\033[0m"
];
private bool $colorSupport;
private int $verbosity;
public function __construct(int $verbosity = self::VERBOSITY_NORMAL)
{
$this->verbosity = $verbosity;
$this->colorSupport = $this->hasColorSupport();
}
public function write(string $message, bool $newline = true): void
{
$output = $this->colorSupport ? $this->applyStyles($message) : strip_tags($message);
echo $output . ($newline ? "\n" : "");
}
public function success(string $message): void
{
$this->write("<success>✓ {$message}</success>");
}
public function error(string $message): void
{
$this->write("<error>✗ {$message}</error>");
error_log($message);
}
public function warning(string $message): void
{
$this->write("<warning>⚠ {$message}</warning>");
}
public function info(string $message): void
{
if ($this->verbosity >= self::VERBOSITY_VERBOSE) {
$this->write("<info>ℹ {$message}</info>");
}
}
public function comment(string $message): void
{
if ($this->verbosity >= self::VERBOSITY_VERY_VERBOSE) {
$this->write("<comment>// {$message}</comment>");
}
}
public function question(string $question, string $default = null): string
{
$prompt = "<question>{$question}</question>";
if ($default !== null) {
$prompt .= " [<comment>{$default}</comment>]";
}
$prompt .= ": ";
$this->write($prompt, false);
$answer = trim(fgets(STDIN));
return $answer === '' ? $default : $answer;
}
public function confirm(string $question, bool $default = false): bool
{
$defaultText = $default ? 'Y/n' : 'y/N';
$answer = $this->question("{$question} ({$defaultText})", $default ? 'y' : 'n');
return in_array(strtolower($answer), ['y', 'yes', '1', 'true']);
}
public function choice(string $question, array $choices, $default = null): string
{
$this->write("<question>{$question}</question>");
foreach ($choices as $key => $choice) {
$marker = ($choice === $default) ? '*' : ' ';
$this->write(" [{$marker}] {$key}) {$choice}");
}
do {
$answer = $this->question("Please select", $default ? array_search($default, $choices) : null);
if (isset($choices[$answer])) {
return $choices[$answer];
}
$this->error("Invalid choice. Please select from the available options.");
} while (true);
}
public function table(array $headers, array $rows): void
{
$columnWidths = [];
// Calculate column widths
foreach ($headers as $i => $header) {
$columnWidths[$i] = strlen($header);
}
foreach ($rows as $row) {
foreach ($row as $i => $cell) {
$columnWidths[$i] = max($columnWidths[$i], strlen($cell));
}
}
// Draw table
$this->drawTableSeparator($columnWidths);
$this->drawTableRow($headers, $columnWidths, true);
$this->drawTableSeparator($columnWidths);
foreach ($rows as $row) {
$this->drawTableRow($row, $columnWidths);
}
$this->drawTableSeparator($columnWidths);
}
public function progressBar(int $max, string $format = null): ProgressBar
{
return new ProgressBar($this, $max, $format);
}
private function drawTableSeparator(array $columnWidths): void
{
$line = '+';
foreach ($columnWidths as $width) {
$line .= str_repeat('-', $width + 2) . '+';
}
$this->write($line);
}
private function drawTableRow(array $row, array $columnWidths, bool $isHeader = false): void
{
$line = '|';
foreach ($row as $i => $cell) {
$padding = $columnWidths[$i] - strlen($cell);
$cellContent = $isHeader ? "<bold>{$cell}</bold>" : $cell;
$line .= " {$cellContent}" . str_repeat(' ', $padding) . ' |';
}
$this->write($line);
}
private function applyStyles(string $message): string
{
return preg_replace_callback('/<(\w+)>(.*?)<\/\1>/', function ($matches) {
$style = $matches[1];
$content = $matches[2];
if (isset($this->styles[$style])) {
return $this->styles[$style] . $content . $this->styles['reset'];
}
return $content;
}, $message);
}
private function hasColorSupport(): bool
{
if (DIRECTORY_SEPARATOR === '\\') {
return (function_exists('sapi_windows_vt100_support') && sapi_windows_vt100_support(STDOUT))
|| getenv('ANSICON') !== false
|| getenv('ConEmuANSI') === 'ON'
|| getenv('TERM') === 'xterm';
}
return function_exists('posix_isatty') && posix_isatty(STDOUT);
}
}
Progress Bar Implementation
Create an advanced progress bar with customizable formatting:
// src/CLI/IO/ProgressBar.php
<?php
namespace App\CLI\IO;
class ProgressBar
{
private OutputInterface $output;
private int $max;
private int $current = 0;
private float $startTime;
private string $format;
private array $formatters;
private bool $finished = false;
public function __construct(OutputInterface $output, int $max, string $format = null)
{
$this->output = $output;
$this->max = $max;
$this->startTime = microtime(true);
$this->format = $format ?? '[%bar%] %current%/%max% (%percent%%) %elapsed%/%estimated% %memory%';
$this->setupFormatters();
$this->display();
}
public function advance(int $step = 1): void
{
$this->current = min($this->current + $step, $this->max);
$this->display();
if ($this->current >= $this->max && !$this->finished) {
$this->finish();
}
}
public function setProgress(int $current): void
{
$this->current = min(max($current, 0), $this->max);
$this->display();
}
public function finish(): void
{
if (!$this->finished) {
$this->current = $this->max;
$this->display();
$this->output->write('');
$this->finished = true;
}
}
private function display(): void
{
if ($this->finished) {
return;
}
$output = $this->format;
foreach ($this->formatters as $placeholder => $formatter) {
$output = str_replace($placeholder, $formatter(), $output);
}
// Clear line and move cursor to beginning
$this->output->write("\r\033[K{$output}", false);
}
private function setupFormatters(): void
{
$this->formatters = [
'%bar%' => function () {
$percent = $this->max > 0 ? $this->current / $this->max : 0;
$barWidth = 50;
$completedWidth = (int) ($barWidth * $percent);
$bar = str_repeat('█', $completedWidth);
$bar .= str_repeat('░', $barWidth - $completedWidth);
return $bar;
},
'%current%' => fn() => number_format($this->current),
'%max%' => fn() => number_format($this->max),
'%percent%' => function () {
$percent = $this->max > 0 ? ($this->current / $this->max) * 100 : 100;
return sprintf('%3d', round($percent));
},
'%elapsed%' => function () {
$elapsed = microtime(true) - $this->startTime;
return $this->formatTime($elapsed);
},
'%estimated%' => function () {
if ($this->current === 0) {
return '--:--';
}
$elapsed = microtime(true) - $this->startTime;
$rate = $this->current / $elapsed;
$remaining = ($this->max - $this->current) / $rate;
return $this->formatTime($remaining);
},
'%memory%' => function () {
$bytes = memory_get_usage(true);
$units = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $bytes > 1024 && $i < 3; $i++) {
$bytes /= 1024;
}
return sprintf('%.1f%s', $bytes, $units[$i]);
},
'%rate%' => function () {
$elapsed = microtime(true) - $this->startTime;
$rate = $elapsed > 0 ? $this->current / $elapsed : 0;
return sprintf('%.1f/s', $rate);
}
];
}
private function formatTime(float $seconds): string
{
$hours = floor($seconds / 3600);
$minutes = floor(($seconds % 3600) / 60);
$seconds = $seconds % 60;
if ($hours > 0) {
return sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds);
} else {
return sprintf('%02d:%02d', $minutes, $seconds);
}
}
}
Real-World CLI Application Examples
File Processing Command
Create a sophisticated file processing command with progress tracking:
// src/CLI/Command/ProcessFilesCommand.php
<?php
namespace App\CLI\Command;
use App\CLI\IO\InputInterface;
use App\CLI\IO\OutputInterface;
class ProcessFilesCommand extends AbstractCommand
{
private array $processors = [];
private array $stats = [];
protected function configure(): void
{
$this
->addArgument('input', true, 'Input directory or file pattern')
->addArgument('output', true, 'Output directory')
->addOption('format', 'f', 'json', 'Output format (json, csv, xml)')
->addOption('threads', 't', 1, 'Number of processing threads')
->addOption('batch-size', 'b', 100, 'Batch size for processing')
->addOption('filter', null, null, 'Filter pattern for files')
->addOption('dry-run', null, false, 'Show what would be processed without actually processing')
->addOption('verbose', 'v', false, 'Verbose output')
->addOption('overwrite', null, false, 'Overwrite existing output files');
}
protected function execute(): int
{
$inputPath = $this->getArgument('input');
$outputPath = $this->getArgument('output');
$format = $this->getOption('format');
$threads = (int) $this->getOption('threads');
$batchSize = (int) $this->getOption('batch-size');
$isDryRun = $this->getOption('dry-run');
$this->output->info("Starting file processing...");
$this->output->info("Input: {$inputPath}");
$this->output->info("Output: {$outputPath}");
$this->output->info("Format: {$format}");
// Discover files
$files = $this->discoverFiles($inputPath);
if (empty($files)) {
$this->output->warning("No files found matching the criteria");
return 0;
}
$this->output->success("Found " . count($files) . " files to process");
if ($isDryRun) {
$this->showDryRunResults($files);
return 0;
}
// Create output directory
if (!is_dir($outputPath) && !mkdir($outputPath, 0755, true)) {
$this->output->error("Could not create output directory: {$outputPath}");
return 1;
}
// Process files
return $this->processFiles($files, $outputPath, $format, $threads, $batchSize);
}
private function discoverFiles(string $inputPath): array
{
$files = [];
$filter = $this->getOption('filter');
if (is_file($inputPath)) {
$files[] = $inputPath;
} elseif (is_dir($inputPath)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($inputPath, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$filePath = $file->getPathname();
if ($filter && !fnmatch($filter, basename($filePath))) {
continue;
}
$files[] = $filePath;
}
}
} else {
// Handle glob patterns
$files = glob($inputPath);
}
return array_filter($files, 'is_file');
}
private function showDryRunResults(array $files): void
{
$this->output->info("Dry run - files that would be processed:");
$headers = ['File', 'Size', 'Modified'];
$rows = [];
foreach (array_slice($files, 0, 10) as $file) {
$rows[] = [
basename($file),
$this->formatBytes(filesize($file)),
date('Y-m-d H:i:s', filemtime($file))
];
}
if (count($files) > 10) {
$rows[] = ['...', '...', '...'];
$rows[] = ['Total: ' . count($files) . ' files', '', ''];
}
$this->output->table($headers, $rows);
}
private function processFiles(array $files, string $outputPath, string $format, int $threads, int $batchSize): int
{
$progressBar = $this->output->progressBar(count($files),
'Processing [%bar%] %current%/%max% (%percent%%) %elapsed%/%estimated% - %rate% files/sec');
$this->stats = [
'processed' => 0,
'skipped' => 0,
'errors' => 0,
'total_size' => 0,
'start_time' => microtime(true)
];
$batches = array_chunk($files, $batchSize);
foreach ($batches as $batch) {
if ($threads > 1) {
$this->processBatchParallel($batch, $outputPath, $format, $threads);
} else {
$this->processBatchSequential($batch, $outputPath, $format);
}
$progressBar->advance(count($batch));
}
$progressBar->finish();
$this->showProcessingStats();
return $this->stats['errors'] > 0 ? 1 : 0;
}
private function processBatchSequential(array $files, string $outputPath, string $format): void
{
foreach ($files as $file) {
try {
$this->processFile($file, $outputPath, $format);
$this->stats['processed']++;
} catch (\Exception $e) {
$this->stats['errors']++;
$this->output->error("Error processing {$file}: " . $e->getMessage());
}
}
}
private function processBatchParallel(array $files, string $outputPath, string $format, int $threads): void
{
// Simple parallel processing using proc_open
$processes = [];
$fileChunks = array_chunk($files, ceil(count($files) / $threads));
foreach ($fileChunks as $i => $chunk) {
$tempFile = tempnam(sys_get_temp_dir(), 'cli_batch_' . $i);
file_put_contents($tempFile, json_encode([
'files' => $chunk,
'output_path' => $outputPath,
'format' => $format
]));
$cmd = sprintf('php %s process-batch %s',
$_SERVER['SCRIPT_FILENAME'],
escapeshellarg($tempFile)
);
$processes[$i] = [
'handle' => proc_open($cmd, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w']
], $pipes),
'pipes' => $pipes,
'temp_file' => $tempFile
];
}
// Wait for all processes to complete
foreach ($processes as $i => $process) {
$output = stream_get_contents($process['pipes'][1]);
$errors = stream_get_contents($process['pipes'][2]);
fclose($process['pipes'][0]);
fclose($process['pipes'][1]);
fclose($process['pipes'][2]);
$exitCode = proc_close($process['handle']);
unlink($process['temp_file']);
if ($exitCode !== 0 && $errors) {
$this->output->error("Batch {$i} failed: {$errors}");
}
}
}
private function processFile(string $filePath, string $outputPath, string $format): void
{
$outputFile = $outputPath . '/' . pathinfo($filePath, PATHINFO_FILENAME) . '.' . $format;
if (file_exists($outputFile) && !$this->getOption('overwrite')) {
if (!$this->output->confirm("File {$outputFile} exists. Overwrite?", false)) {
$this->stats['skipped']++;
return;
}
}
$data = $this->extractDataFromFile($filePath);
$this->writeFormattedData($data, $outputFile, $format);
$this->stats['total_size'] += filesize($filePath);
}
private function extractDataFromFile(string $filePath): array
{
// Simulate data extraction - in real implementation,
// this would parse the file content
return [
'file' => basename($filePath),
'path' => $filePath,
'size' => filesize($filePath),
'modified' => filemtime($filePath),
'content_hash' => md5_file($filePath),
'line_count' => $this->countLines($filePath),
'encoding' => mb_detect_encoding(file_get_contents($filePath, false, null, 0, 1024)),
];
}
private function writeFormattedData(array $data, string $outputFile, string $format): void
{
switch ($format) {
case 'json':
file_put_contents($outputFile, json_encode($data, JSON_PRETTY_PRINT));
break;
case 'csv':
$fp = fopen($outputFile, 'w');
fputcsv($fp, array_keys($data));
fputcsv($fp, array_values($data));
fclose($fp);
break;
case 'xml':
$xml = new \SimpleXMLElement('<file/>');
foreach ($data as $key => $value) {
$xml->addChild($key, htmlspecialchars($value));
}
$xml->asXML($outputFile);
break;
default:
throw new \InvalidArgumentException("Unsupported format: {$format}");
}
}
private function countLines(string $filePath): int
{
$count = 0;
$handle = fopen($filePath, 'r');
if ($handle) {
while (($line = fgets($handle)) !== false) {
$count++;
}
fclose($handle);
}
return $count;
}
private function showProcessingStats(): void
{
$duration = microtime(true) - $this->stats['start_time'];
$this->output->write('');
$this->output->success("Processing completed!");
$headers = ['Metric', 'Value'];
$rows = [
['Files processed', number_format($this->stats['processed'])],
['Files skipped', number_format($this->stats['skipped'])],
['Errors', number_format($this->stats['errors'])],
['Total size processed', $this->formatBytes($this->stats['total_size'])],
['Duration', $this->formatDuration($duration)],
['Average rate', sprintf('%.2f files/sec', $this->stats['processed'] / $duration)],
];
$this->output->table($headers, $rows);
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
for ($i = 0; $bytes > 1024 && $i < 4; $i++) {
$bytes /= 1024;
}
return sprintf('%.2f %s', $bytes, $units[$i]);
}
private function formatDuration(float $seconds): string
{
if ($seconds < 60) {
return sprintf('%.2fs', $seconds);
} elseif ($seconds < 3600) {
return sprintf('%dm %.2fs', floor($seconds / 60), $seconds % 60);
} else {
return sprintf('%dh %dm %.2fs',
floor($seconds / 3600),
floor(($seconds % 3600) / 60),
$seconds % 60
);
}
}
}
Application Container and Service Locator
Build a dependency injection container for CLI applications:
// src/CLI/Container/Container.php
<?php
namespace App\CLI\Container;
class Container
{
private array $services = [];
private array $singletons = [];
private array $instances = [];
public function bind(string $abstract, $concrete = null): void
{
if ($concrete === null) {
$concrete = $abstract;
}
$this->services[$abstract] = $concrete;
}
public function singleton(string $abstract, $concrete = null): void
{
$this->bind($abstract, $concrete);
$this->singletons[$abstract] = true;
}
public function instance(string $abstract, $instance): void
{
$this->instances[$abstract] = $instance;
}
public function make(string $abstract)
{
// Return existing instance if singleton
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
// Resolve the concrete implementation
$concrete = $this->services[$abstract] ?? $abstract;
if ($concrete instanceof \Closure) {
$instance = $concrete($this);
} elseif (is_string($concrete)) {
$instance = $this->build($concrete);
} else {
$instance = $concrete;
}
// Store instance if singleton
if (isset($this->singletons[$abstract])) {
$this->instances[$abstract] = $instance;
}
return $instance;
}
private function build(string $concrete)
{
$reflection = new \ReflectionClass($concrete);
if (!$reflection->isInstantiable()) {
throw new \Exception("Class {$concrete} is not instantiable");
}
$constructor = $reflection->getConstructor();
if ($constructor === null) {
return new $concrete;
}
$dependencies = $this->resolveDependencies($constructor->getParameters());
return $reflection->newInstanceArgs($dependencies);
}
private function resolveDependencies(array $parameters): array
{
$dependencies = [];
foreach ($parameters as $parameter) {
$type = $parameter->getType();
if ($type === null) {
if ($parameter->isDefaultValueAvailable()) {
$dependencies[] = $parameter->getDefaultValue();
} else {
throw new \Exception("Cannot resolve parameter {$parameter->getName()}");
}
} elseif ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
$dependencies[] = $this->make($type->getName());
} else {
if ($parameter->isDefaultValueAvailable()) {
$dependencies[] = $parameter->getDefaultValue();
} else {
throw new \Exception("Cannot resolve parameter {$parameter->getName()}");
}
}
}
return $dependencies;
}
public function has(string $abstract): bool
{
return isset($this->services[$abstract]) || isset($this->instances[$abstract]);
}
}
Related Posts
For more insights into PHP CLI development and advanced PHP programming techniques, explore these related articles:
- PHP CLI Applications: Building Command Line Tools
- Building Custom PHP Extensions: A Practical Guide
- PHP Performance Profiling and Optimization Techniques
Conclusion
Building professional PHP CLI applications requires embracing modern patterns, sophisticated architectures, and user-centric design. By implementing command patterns, advanced I/O systems, progress tracking, and dependency injection, you can create CLI tools that rival applications built in traditional CLI languages.
Key principles for professional CLI development:
- Architecture First: Design with extensibility and maintainability in mind
- User Experience: Provide clear feedback, progress indication, and error handling
- Performance Awareness: Implement efficient processing for large datasets
- Error Resilience: Handle failures gracefully with meaningful error messages
- Configuration Driven: Allow users to customize behavior through options and configs
- Parallel Processing: Leverage multi-threading for compute-intensive tasks
Modern PHP CLI applications can handle complex workflows, process large datasets, provide interactive interfaces, and integrate seamlessly with existing systems. The combination of PHP's flexibility, rich ecosystem, and robust CLI capabilities makes it an excellent choice for building sophisticated command-line tools.
Remember that great CLI applications focus on solving real problems efficiently while providing excellent user experiences. Start with clear requirements, design for your users, and iterate based on feedback to create tools that developers and system administrators will actually want to use.
Add Comment
No comments yet. Be the first to comment!