Creating Custom Commands
Extend Craftsman CLI with your own custom commands to automate repetitive tasks, create deployment scripts, and build powerful developer tools tailored to your application.
Custom Commands Overview
ZephyrPHP's Craftsman CLI is built on Symfony Console, making it easy to create custom commands that integrate seamlessly with the framework. Commands are auto-discovered from the src/Commands/ directory.
Why Create Custom Commands?
- Automation: Automate repetitive development tasks
- Data Management: Seed databases, import/export data
- Deployment: Create deployment and maintenance scripts
- Utilities: Build developer tools specific to your application
- Scheduled Tasks: Create commands for cron jobs
Creating a Basic Command
Command Structure
Create a new file in src/Commands/ directory:
<?php
namespace App\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(
name: 'greet',
description: 'Greet the user'
)]
class GreetCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Hello from Craftsman!');
return Command::SUCCESS;
}
}
File Location
Save as src/Commands/GreetCommand.php
Using Your Command
# Commands are auto-discovered
php craftsman greet
# Output:
Hello from Craftsman!
Command Anatomy
Required Elements
1. Namespace
namespace App\Commands;
2. Extend Command
class MyCommand extends Command
3. AsCommand Attribute
#[AsCommand(
name: 'command:name',
description: 'What this command does'
)]
4. Execute Method
protected function execute(InputInterface $input, OutputInterface $output): int
{
// Command logic here
return Command::SUCCESS; // or Command::FAILURE
}
Naming Conventions
| Pattern | Example | Purpose |
|---|---|---|
namespace:action |
user:create |
Grouped related commands |
resource:action |
cache:clear |
Actions on resources |
make:type |
make:controller |
Code generation |
simple-name |
deploy |
Standalone utilities |
Arguments and Options
Adding Arguments
Arguments are required or optional positional parameters.
<?php
namespace App\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(
name: 'user:greet',
description: 'Greet a user by name'
)]
class UserGreetCommand extends Command
{
protected function configure(): void
{
$this
->addArgument('name', InputArgument::REQUIRED, 'User name')
->addArgument('title', InputArgument::OPTIONAL, 'User title', 'Guest');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$name = $input->getArgument('name');
$title = $input->getArgument('title');
$output->writeln("Hello, {$title} {$name}!");
return Command::SUCCESS;
}
}
Usage
# With required argument
php craftsman user:greet John
# Output: Hello, Guest John!
# With optional argument
php craftsman user:greet John "Mr."
# Output: Hello, Mr. John!
Argument Types
InputArgument::REQUIRED- Must be providedInputArgument::OPTIONAL- Can be omittedInputArgument::IS_ARRAY- Accept multiple values
Adding Options
Options are named parameters with -- prefix.
<?php
namespace App\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(
name: 'report:generate',
description: 'Generate application report'
)]
class ReportGenerateCommand extends Command
{
protected function configure(): void
{
$this
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format', 'text')
->addOption('verbose', 'v', InputOption::VALUE_NONE, 'Verbose output')
->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output file path');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$format = $input->getOption('format');
$verbose = $input->getOption('verbose');
$outputFile = $input->getOption('output');
if ($verbose) {
$output->writeln("Generating report in {$format} format...");
}
// Generate report logic here
if ($outputFile) {
$output->writeln("Report saved to: {$outputFile}");
}
return Command::SUCCESS;
}
}
Usage
# With options
php craftsman report:generate --format=json --verbose
# Short options
php craftsman report:generate -f json -v
# With output file
php craftsman report:generate --output=report.txt
# Combined
php craftsman report:generate -f json -v -o report.json
Option Types
InputOption::VALUE_REQUIRED- Value must be providedInputOption::VALUE_OPTIONAL- Value can be omittedInputOption::VALUE_NONE- Boolean flag (no value)InputOption::VALUE_IS_ARRAY- Accept multiple values
Interactive Commands
Asking Questions
<?php
namespace App\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(
name: 'setup:wizard',
description: 'Interactive setup wizard'
)]
class SetupWizardCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$helper = $this->getHelper('question');
// Text question
$question = new Question('What is your name? ', 'Guest');
$name = $helper->ask($input, $output, $question);
// Confirmation question
$question = new ConfirmationQuestion('Continue? [y/N] ', false);
if (!$helper->ask($input, $output, $question)) {
return Command::SUCCESS;
}
// Choice question
$question = new ChoiceQuestion(
'Select your favorite color:',
['red', 'blue', 'green'],
0 // default index
);
$color = $helper->ask($input, $output, $question);
$output->writeln("Hello {$name}, you selected {$color}!");
return Command::SUCCESS;
}
}
Hidden Input (Passwords)
$question = new Question('Enter password: ');
$question->setHidden(true);
$question->setHiddenFallback(false);
$password = $helper->ask($input, $output, $question);
Input Validation
$question = new Question('Enter email: ');
$question->setValidator(function ($value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid email address');
}
return $value;
});
$email = $helper->ask($input, $output, $question);
Output Formatting
Styled Output
protected function execute(InputInterface $input, OutputInterface $output): int
{
// Success message (green)
$output->writeln('Operation successful! ');
// Error message (red)
$output->writeln('Operation failed! ');
// Warning message (yellow)
$output->writeln('Warning: This is risky! ');
// Question (cyan)
$output->writeln('What should I do? ');
return Command::SUCCESS;
}
Progress Bar
use Symfony\Component\Console\Helper\ProgressBar;
protected function execute(InputInterface $input, OutputInterface $output): int
{
$items = range(1, 100);
$progressBar = new ProgressBar($output, count($items));
$progressBar->start();
foreach ($items as $item) {
// Process item
sleep(1);
$progressBar->advance();
}
$progressBar->finish();
$output->writeln(''); // New line after progress bar
return Command::SUCCESS;
}
Tables
use Symfony\Component\Console\Helper\Table;
protected function execute(InputInterface $input, OutputInterface $output): int
{
$table = new Table($output);
$table
->setHeaders(['ID', 'Name', 'Email'])
->setRows([
[1, 'John Doe', 'john@example.com'],
[2, 'Jane Smith', 'jane@example.com'],
[3, 'Bob Johnson', 'bob@example.com'],
]);
$table->render();
return Command::SUCCESS;
}
Practical Examples
Database Seeder
<?php
namespace App\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use App\Models\User;
use Doctrine\ORM\EntityManagerInterface;
#[AsCommand(
name: 'db:seed',
description: 'Seed the database with sample data'
)]
class DatabaseSeedCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Seeding database... ');
// Create 10 sample users
for ($i = 1; $i <= 10; $i++) {
$user = new User();
$user->setName("User {$i}");
$user->setEmail("user{$i}@example.com");
$user->setPassword(password_hash('password', PASSWORD_DEFAULT));
$this->entityManager->persist($user);
}
$this->entityManager->flush();
$output->writeln('✓ Created 10 users ');
return Command::SUCCESS;
}
}
Cleanup Command
<?php
namespace App\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(
name: 'cleanup',
description: 'Clean up old files and logs'
)]
class CleanupCommand extends Command
{
protected function configure(): void
{
$this
->addOption('days', 'd', InputOption::VALUE_REQUIRED, 'Days to keep', 30)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$days = $input->getOption('days');
$dryRun = $input->getOption('dry-run');
$output->writeln("Cleaning up files older than {$days} days... ");
$directories = [
'storage/logs/',
'storage/cache/views/',
'storage/temp/',
];
$deletedCount = 0;
foreach ($directories as $dir) {
$files = glob($dir . '*');
$cutoff = time() - ($days * 86400);
foreach ($files as $file) {
if (is_file($file) && filemtime($file) < $cutoff) {
if ($dryRun) {
$output->writeln("Would delete: {$file}");
} else {
unlink($file);
$output->writeln("Deleted: {$file}");
}
$deletedCount++;
}
}
}
if ($dryRun) {
$output->writeln("Would delete {$deletedCount} files (dry run) ");
} else {
$output->writeln("✓ Deleted {$deletedCount} files ");
}
return Command::SUCCESS;
}
}
Data Export Command
<?php
namespace App\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Attribute\AsCommand;
use App\Models\User;
use Doctrine\ORM\EntityManagerInterface;
#[AsCommand(
name: 'export:users',
description: 'Export users to CSV file'
)]
class ExportUsersCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output file', 'users.csv');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$outputFile = $input->getOption('output');
$output->writeln("Exporting users to {$outputFile}... ");
$users = $this->entityManager->getRepository(User::class)->findAll();
$fp = fopen($outputFile, 'w');
fputcsv($fp, ['ID', 'Name', 'Email', 'Created At']);
foreach ($users as $user) {
fputcsv($fp, [
$user->getId(),
$user->getName(),
$user->getEmail(),
$user->getCreatedAt()->format('Y-m-d H:i:s'),
]);
}
fclose($fp);
$output->writeln("✓ Exported " . count($users) . " users to {$outputFile} ");
return Command::SUCCESS;
}
}
Deployment Command
<?php
namespace App\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(
name: 'deploy',
description: 'Deploy application to production'
)]
class DeployCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$helper = $this->getHelper('question');
// Confirm deployment
$question = new ConfirmationQuestion(
'Deploy to production? [y/N] ',
false
);
if (!$helper->ask($input, $output, $question)) {
$output->writeln('Deployment cancelled ');
return Command::SUCCESS;
}
$output->writeln('Starting deployment... ');
// Step 1: Pull latest code
$output->write('Pulling latest code... ');
exec('git pull origin main', $gitOutput, $gitReturn);
if ($gitReturn === 0) {
$output->writeln('✓ ');
} else {
$output->writeln('✗ ');
return Command::FAILURE;
}
// Step 2: Install dependencies
$output->write('Installing dependencies... ');
exec('composer install --no-dev --optimize-autoloader', $composerOutput, $composerReturn);
if ($composerReturn === 0) {
$output->writeln('✓ ');
} else {
$output->writeln('✗ ');
return Command::FAILURE;
}
// Step 3: Clear cache
$output->write('Clearing cache... ');
exec('php craftsman cache:clear', $cacheOutput, $cacheReturn);
if ($cacheReturn === 0) {
$output->writeln('✓ ');
} else {
$output->writeln('✗ ');
return Command::FAILURE;
}
// Step 4: Run migrations
$output->write('Running migrations... ');
exec('php craftsman migrate', $migrateOutput, $migrateReturn);
if ($migrateReturn === 0) {
$output->writeln('✓ ');
} else {
$output->writeln('✗ ');
return Command::FAILURE;
}
$output->writeln('');
$output->writeln('✓ Deployment completed successfully! ');
return Command::SUCCESS;
}
}
Dependency Injection
Commands support constructor dependency injection:
<?php
namespace App\Commands;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
#[AsCommand(
name: 'process:data',
description: 'Process data with services'
)]
class ProcessDataCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private LoggerInterface $logger
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->logger->info('Processing data...');
// Use entity manager
$users = $this->entityManager->getRepository(User::class)->findAll();
// Process users...
$this->logger->info('Processing complete');
return Command::SUCCESS;
}
}
Testing Commands
Using CommandTester
<?php
namespace Tests\Commands;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
use App\Commands\GreetCommand;
class GreetCommandTest extends TestCase
{
public function testExecute()
{
$command = new GreetCommand();
$tester = new CommandTester($command);
$tester->execute([]);
$output = $tester->getDisplay();
$this->assertStringContainsString('Hello from Craftsman!', $output);
$this->assertEquals(0, $tester->getStatusCode());
}
public function testExecuteWithArguments()
{
$command = new UserGreetCommand();
$tester = new CommandTester($command);
$tester->execute(['name' => 'John']);
$output = $tester->getDisplay();
$this->assertStringContainsString('Hello, Guest John!', $output);
}
}
Best Practices
Command Design
- Single responsibility: One command does one thing well
- Clear naming: Use descriptive names (verb:noun pattern)
- Good help text: Provide clear descriptions and examples
- Exit codes: Return
Command::SUCCESSorCommand::FAILURE
User Experience
- Provide feedback: Show progress, success, and error messages
- Confirm destructive actions: Ask before deleting data
- Support dry-run: Show what would happen without doing it
- Handle errors gracefully: Display helpful error messages
Error Handling
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
// Command logic
return Command::SUCCESS;
} catch (\Exception $e) {
$output->writeln("Error: {$e->getMessage()} ");
return Command::FAILURE;
}
}
Troubleshooting
Command Not Found
# Ensure file is in correct location
ls src/Commands/MyCommand.php
# Ensure class name ends with "Command"
# File: MyCommand.php
# Class: MyCommand extends Command
# Check #[AsCommand] attribute exists
# Clear cache
php craftsman cache:clear
Dependency Injection Errors
# Ensure services are registered
# Check config/services.php
# Verify constructor parameters are type-hinted
public function __construct(
private EntityManagerInterface $entityManager
) {
parent::__construct();
}
# Always call parent::__construct()!
Next Steps
Now that you can create custom commands:
- Review all Craftsman CLI commands
- Learn about Database Management
- Explore Code Generation features
- Read Symfony Console Documentation