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...
}