Architecture

Command Standards

File Organization

app/Console/Commands/
├── CleanupInactiveUsers.php
├── SendDailyReports.php
├── SyncExternalData.php
└── Maintenance/
    ├── PruneOldRecords.php
    └── OptimizeDatabase.php

Naming Conventions

  • Use descriptive names that indicate the action: SendDailyReports, CleanupInactiveUsers
  • Use PascalCase for class names
  • Use kebab-case for command signatures: app:send-daily-reports
  • Prefix with app: for application commands
// Good
protected $signature = 'app:cleanup-inactive-users';
protected $signature = 'app:sync-external-data';

// Bad
protected $signature = 'cleanup';           // Too vague
protected $signature = 'CleanupUsers';      // Wrong case
protected $signature = 'users:cleanup';     // Inconsistent prefix

Basic Structure

<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Actions\Users\DeactivateInactiveUsers;
use Illuminate\Console\Command;

final class CleanupInactiveUsers extends Command
{
    protected $signature = 'app:cleanup-inactive-users
                            {--days=30 : Days of inactivity threshold}
                            {--dry-run : Run without making changes}';

    protected $description = 'Deactivate users who have been inactive for a specified period';

    public function handle(DeactivateInactiveUsers $deactivateUsers): int
    {
        $days = (int) $this->option('days');
        $dryRun = (bool) $this->option('dry-run');

        if ($dryRun) {
            $this->comment('Running in dry-run mode...');
        }

        $this->info("Finding users inactive for {$days}+ days...");

        $count = $deactivateUsers->execute($days, $dryRun);

        $this->info("{$count} users " . ($dryRun ? 'would be' : 'were') . ' deactivated.');

        return Command::SUCCESS;
    }
}

Dependency Injection

Inject dependencies in the handle method, not the constructor:

// Good - Inject in handle()
public function handle(
    UserRepository $users,
    NotificationService $notifications,
): int {
    // ...
}

// Bad - Constructor injection
public function __construct(
    private UserRepository $users,
) {
    parent::__construct();
}

Signature Definition

Arguments

// Required argument
protected $signature = 'app:process-user {user}';

// Optional argument
protected $signature = 'app:process-user {user?}';

// Argument with default
protected $signature = 'app:process-user {user=1}';

// Array argument
protected $signature = 'app:process-users {users*}';

// With description
protected $signature = 'app:process-user {user : The user ID or email}';

Options

// Boolean flag
protected $signature = 'app:export {--force}';

// Option with value
protected $signature = 'app:export {--format=csv}';

// Option with shortcut
protected $signature = 'app:export {--F|format=csv}';

// Required option value
protected $signature = 'app:export {--format=}';

// Array option
protected $signature = 'app:notify {--channel=*}';

Accessing Values

public function handle(): int
{
    // Arguments
    $userId = $this->argument('user');
    $allArgs = $this->arguments();

    // Options
    $format = $this->option('format');
    $force = $this->option('force');  // Returns true/false for flags
    $allOptions = $this->options();

    return Command::SUCCESS;
}

Output Methods

Standard Output

// Information (green)
$this->info('Process completed successfully.');

// Comments (yellow)
$this->comment('Skipping already processed items...');

// Questions (cyan)
$this->question('Are you sure?');

// Errors (red)
$this->error('Failed to connect to database.');

// Plain output
$this->line('Processing item 1 of 100');

// Blank lines
$this->newLine();
$this->newLine(2);  // Multiple blank lines

Verbosity Levels

// Only show with -v flag
$this->info('Detailed step info', 'v');

// Only show with -vv flag
$this->info('Very detailed info', 'vv');

// Only show with -vvv flag
$this->info('Debug level info', 'vvv');

// Check verbosity manually
if ($this->output->isVerbose()) {
    $this->line("Processing: {$item->name}");
}

Tables

$this->table(
    ['Name', 'Email', 'Status'],
    $users->map(fn ($user) => [
        $user->name,
        $user->email,
        $user->is_active ? 'Active' : 'Inactive',
    ])->toArray()
);

Progress Bars

$users = User::inactive()->cursor();
$bar = $this->output->createProgressBar($users->count());

$bar->start();

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

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

// Or use withProgressBar helper
$this->withProgressBar($users, function (User $user): void {
    $this->processUser($user);
});

Interactive Prompts

// Confirmation
if (!$this->confirm('Do you want to continue?')) {
    return Command::SUCCESS;
}

// With default
if ($this->confirm('Delete all records?', false)) {
    // ...
}

// Text input
$name = $this->ask('What is your name?');
$name = $this->ask('What is your name?', 'Default');

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

// Choice
$choice = $this->choice('Select environment', ['local', 'staging', 'production'], 0);

// Multiple choice
$choices = $this->choice(
    'Select roles',
    ['admin', 'editor', 'viewer'],
    null,
    null,
    true  // Allow multiple
);

// Anticipate (autocomplete)
$name = $this->anticipate('Search user', ['John', 'Jane', 'Bob']);

Exit Codes

// Success (implicit - don't return 0 explicitly)
public function handle(): int
{
    $this->info('Done.');

    return Command::SUCCESS;
}

// Failure
public function handle(): int
{
    if (!$this->canProcess()) {
        $this->error('Cannot process at this time.');

        return Command::FAILURE;
    }

    // ...

    return Command::SUCCESS;
}

// Using fail() helper (returns FAILURE and outputs error)
public function handle(): int
{
    if (!$user = User::find($this->argument('user'))) {
        return $this->fail('User not found.');
    }

    // ...

    return Command::SUCCESS;
}

// Invalid input
public function handle(): int
{
    if (!in_array($this->option('format'), ['csv', 'json', 'xml'])) {
        $this->error('Invalid format. Use: csv, json, or xml');

        return Command::INVALID;
    }

    return Command::SUCCESS;
}

Error Handling

public function handle(ExportService $exporter): int
{
    try {
        $exporter->export($this->option('format'));
        $this->info('Export completed.');

        return Command::SUCCESS;
    } catch (ExportException $e) {
        $this->error("Export failed: {$e->getMessage()}");

        return Command::FAILURE;
    }
}

Calling Other Commands

// Call another command
$this->call('app:send-notifications', [
    'user' => $userId,
    '--force' => true,
]);

// Call silently (no output)
$this->callSilently('cache:clear');

Scheduling

Register commands in routes/console.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('app:cleanup-inactive-users')
    ->daily()
    ->at('03:00')
    ->withoutOverlapping()
    ->onOneServer();

Schedule::command('app:send-daily-reports')
    ->dailyAt('08:00')
    ->emailOutputOnFailure(config('mail.admin'));

Schedule::command('app:sync-external-data')
    ->everyFifteenMinutes()
    ->runInBackground();

Testing Commands

<?php

declare(strict_types=1);

namespace Tests\Feature\Commands;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class CleanupInactiveUsersTest extends TestCase
{
    use RefreshDatabase;

    public function test_deactivates_inactive_users(): void
    {
        $activeUser = User::factory()->create(['last_login_at' => now()]);
        $inactiveUser = User::factory()->create(['last_login_at' => now()->subDays(60)]);

        $this->artisan('app:cleanup-inactive-users', ['--days' => 30])
            ->expectsOutput('1 users were deactivated.')
            ->assertSuccessful();

        $this->assertTrue($activeUser->fresh()->is_active);
        $this->assertFalse($inactiveUser->fresh()->is_active);
    }

    public function test_dry_run_does_not_modify_users(): void
    {
        $inactiveUser = User::factory()->create(['last_login_at' => now()->subDays(60)]);

        $this->artisan('app:cleanup-inactive-users', ['--dry-run' => true])
            ->expectsOutput('Running in dry-run mode...')
            ->assertSuccessful();

        $this->assertTrue($inactiveUser->fresh()->is_active);
    }

    public function test_confirms_before_destructive_action(): void
    {
        $this->artisan('app:purge-records')
            ->expectsConfirmation('This will delete all old records. Continue?', 'no')
            ->assertSuccessful();
    }
}

Best Practices

Use Actions for Business Logic

// Good - Delegate to action
public function handle(DeactivateInactiveUsers $action): int
{
    $count = $action->execute(
        days: (int) $this->option('days'),
        dryRun: (bool) $this->option('dry-run'),
    );

    $this->info("{$count} users processed.");

    return Command::SUCCESS;
}

// Bad - Business logic in command
public function handle(): int
{
    $users = User::where('last_login_at', '<', now()->subDays(30))->get();

    foreach ($users as $user) {
        $user->update(['is_active' => false]);
        // Send notification...
        // Log activity...
    }

    return Command::SUCCESS;
}

Always Add Descriptions

protected $description = 'Deactivate users inactive for more than N days';

// Include in signature for arguments/options
protected $signature = 'app:cleanup
    {--days=30 : Number of days of inactivity}
    {--dry-run : Preview changes without applying}';

Support Dry Run for Destructive Commands

protected $signature = 'app:purge-records
    {--dry-run : Show what would be deleted without deleting}';

public function handle(): int
{
    $records = Record::old()->get();

    if ($this->option('dry-run')) {
        $this->comment("Would delete {$records->count()} records:");
        $this->table(['ID', 'Created'], $records->map->only(['id', 'created_at']));

        return Command::SUCCESS;
    }

    // Actually delete...
}

Related Documentation