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 provided
  • InputArgument::OPTIONAL - Can be omitted
  • InputArgument::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 provided
  • InputOption::VALUE_OPTIONAL - Value can be omitted
  • InputOption::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::SUCCESS or Command::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: