Laravel Custom Artisan Commands: Building Powerful CLI Tools for Development and Production

14 minute read Laravel Mastery · Part 2

Master Laravel's Artisan console by building custom commands for automation, multi-tenant systems, data processing, and deployment workflows. Learn advanced patterns, scheduling, testing, and real-world implementations with practical examples.

Laravel’s Artisan console is far more than a collection of built-in commands for migrations and cache clearing. It’s a powerful, extensible CLI framework that enables you to build custom automation tools, streamline development workflows, and create production-ready task runners. Whether you’re managing multi-tenant systems, processing large datasets, or automating deployment tasks, custom Artisan commands are essential tools in your Laravel arsenal.

This comprehensive guide covers everything from basic command creation to advanced patterns, including argument handling, progress indicators, command scheduling, testing strategies, and real-world implementations.

Why Build Custom Artisan Commands?

Development Workflow Automation

Problem: Repetitive manual tasks slow development and introduce human error.

Solution: Automate with commands:

  • Database seeding with realistic test data
  • Cache warming for development environments
  • Asset compilation and optimisation
  • Code generation and scaffolding

Production Operations

Critical operations: Commands provide reliable, repeatable processes:

  • Data import/export operations
  • Report generation and distribution
  • Cleanup and maintenance tasks
  • System health checks and monitoring

Multi-Tenant Architecture

Manage complex multi-tenant systems efficiently:

  • Per-tenant database migrations
  • Isolated cache management
  • Tenant-specific data processing
  • Bulk tenant operations

Integration Workflows

Connect Laravel with external systems:

  • Third-party API synchronisation
  • Payment gateway reconciliation
  • Search index rebuilding
  • Email campaign processing

Learn how custom commands integrate with Laravel middleware patterns and event-driven architecture.

Creating Your First Command

Basic Command Structure

Generate a new command using Artisan’s scaffolding:

php artisan make:command GenerateSalesReport

This creates app/Console/Commands/GenerateSalesReport.php:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class GenerateSalesReport extends Command
{
    /**
     * The name and signature of the console command.
     */
    protected $signature = 'report:sales';

    /**
     * The console command description.
     */
    protected $description = 'Generate daily sales report and email to management';

    /**
     * Execute the console command.
     */
    public function handle(): int
    {
        $this->info('Generating sales report...');

        // Your logic here

        $this->info('Report generated successfully!');

        return Command::SUCCESS;
    }
}

Command Signature Components

The signature defines how users interact with your command:

protected $signature = 'namespace:action {argument} {--option}';

Components:

  • namespace:action - Command name (use colons for grouping)
  • {argument} - Required positional argument
  • {argument?} - Optional argument
  • {argument=default} - Argument with default value
  • {--option} - Boolean option (flag)
  • {--option=} - Option with value
  • {--option=default} - Option with default value

Return Codes

Always return appropriate exit codes:

public function handle(): int
{
    try {
        // Your logic
        return Command::SUCCESS;    // 0
    } catch (\Exception $e) {
        $this->error($e->getMessage());
        return Command::FAILURE;    // 1
    }
}

Exit codes matter for:

  • CI/CD pipeline integration
  • Cron job monitoring
  • Error detection and alerting
  • Script chaining

Arguments and Options: Building Dynamic Commands

Required Arguments

protected $signature = 'user:create {name} {email}';

public function handle(): int
{
    $name = $this->argument('name');
    $email = $this->argument('email');

    User::create([
        'name' => $name,
        'email' => $email,
        'password' => Hash::make(Str::random(16)),
    ]);

    $this->info("User {$name} created successfully!");

    return Command::SUCCESS;
}

Usage:

php artisan user:create "John Smith" john@example.com

Optional Arguments with Defaults

protected $signature = 'report:generate {type=sales} {period=daily}';

public function handle(): int
{
    $type = $this->argument('type');
    $period = $this->argument('period');

    $this->info("Generating {$period} {$type} report...");

    // Generate report logic

    return Command::SUCCESS;
}

Array Arguments

protected $signature = 'email:send {users*}';

public function handle(): int
{
    $userIds = $this->argument('users');

    foreach ($userIds as $userId) {
        $user = User::find($userId);
        Mail::to($user)->send(new WelcomeEmail());
    }

    $this->info('Emails sent to ' . count($userIds) . ' users');

    return Command::SUCCESS;
}

Usage:

php artisan email:send 1 2 3 4 5

Boolean Options (Flags)

protected $signature = 'cache:clear {--force}';

public function handle(): int
{
    if (!$this->option('force')) {
        if (!$this->confirm('Are you sure you want to clear the cache?')) {
            $this->warn('Cache clear cancelled.');
            return Command::SUCCESS;
        }
    }

    Cache::flush();
    $this->info('Cache cleared successfully!');

    return Command::SUCCESS;
}

Value Options

protected $signature = 'export:users {--format=csv} {--limit=100}';

public function handle(): int
{
    $format = $this->option('format');
    $limit = (int) $this->option('limit');

    $users = User::limit($limit)->get();

    match($format) {
        'json' => $this->exportJson($users),
        'csv' => $this->exportCsv($users),
        'xml' => $this->exportXml($users),
        default => $this->error("Unsupported format: {$format}")
    };

    return Command::SUCCESS;
}

Input Descriptions

Document your command interface:

protected $signature = 'deploy:application
    {environment : The environment to deploy (staging|production)}
    {--branch=master : Git branch to deploy}
    {--skip-tests : Skip running tests before deployment}
    {--rollback : Rollback to previous version}';

protected $description = 'Deploy application to specified environment with safety checks';

Users can see documentation with:

php artisan help deploy:application

See Laravel deployment strategies for production workflows.

User Interaction: Building Engaging CLIs

Output Methods

Laravel provides styled output methods:

public function handle(): int
{
    $this->info('Informational message');      // Green
    $this->comment('Comment or note');         // Yellow
    $this->question('Question prompt');        // Cyan
    $this->error('Error message');             // Red
    $this->warn('Warning message');            // Yellow
    $this->line('Plain message');              // No styling

    // New line
    $this->newLine();
    $this->newLine(3); // Multiple lines

    return Command::SUCCESS;
}

Tables

Display structured data beautifully:

public function handle(): int
{
    $users = User::with('orders')
        ->select('id', 'name', 'email', 'created_at')
        ->limit(10)
        ->get();

    $this->table(
        ['ID', 'Name', 'Email', 'Joined', 'Orders'],
        $users->map(fn($user) => [
            $user->id,
            $user->name,
            $user->email,
            $user->created_at->diffForHumans(),
            $user->orders->count()
        ])
    );

    return Command::SUCCESS;
}

Progress Bars

Show progress for long-running operations:

public function handle(): int
{
    $users = User::all();

    $bar = $this->output->createProgressBar($users->count());
    $bar->start();

    foreach ($users as $user) {
        // Process user
        $this->processUser($user);

        $bar->advance();
    }

    $bar->finish();
    $this->newLine();
    $this->info('All users processed!');

    return Command::SUCCESS;
}

Advanced progress bar:

$bar = $this->output->createProgressBar(count($items));

// Customise display
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%');

// Set bar width
$bar->setBarWidth(50);

// Custom messages
$bar->setMessage('Starting...', 'status');
$bar->setFormat('%status% %current%/%max% [%bar%] %percent:3s%%');

$bar->start();

foreach ($items as $item) {
    $bar->setMessage("Processing {$item->name}...", 'status');
    $bar->advance();
}

$bar->finish();

User Confirmation

if ($this->confirm('Do you want to continue?')) {
    // User confirmed
}

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

Choice Selection

$role = $this->choice(
    'Select user role',
    ['admin', 'editor', 'viewer'],
    0  // Default index
);

// Multiple selection
$permissions = $this->choice(
    'Select permissions',
    ['read', 'write', 'delete', 'admin'],
    null,
    null,
    true  // Allow multiple
);

Text Input

$name = $this->ask('What is your name?');

// With default
$email = $this->ask('Email address', 'user@example.com');

// Password (hidden input)
$password = $this->secret('Enter password');

// Anticipate (autocomplete)
$country = $this->anticipate('Country', ['UK', 'USA', 'Canada']);

Real-World Command Implementations

Multi-Tenant Database Migration

<?php

namespace App\Console\Commands;

use App\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;

class MigrateTenants extends Command
{
    protected $signature = 'tenants:migrate
        {--tenant= : Migrate specific tenant by ID}
        {--fresh : Run fresh migrations}
        {--seed : Run seeders after migration}
        {--force : Force migration in production}';

    protected $description = 'Run migrations for all tenants or specific tenant';

    public function handle(): int
    {
        $tenants = $this->option('tenant')
            ? Tenant::where('id', $this->option('tenant'))->get()
            : Tenant::where('is_active', true)->get();

        if ($tenants->isEmpty()) {
            $this->error('No tenants found.');
            return Command::FAILURE;
        }

        $this->info("Migrating {$tenants->count()} tenant(s)...");
        $bar = $this->output->createProgressBar($tenants->count());

        $successful = 0;
        $failed = 0;

        foreach ($tenants as $tenant) {
            try {
                $this->migrateTenant($tenant);
                $successful++;
            } catch (\Exception $e) {
                $this->error("\nFailed for tenant {$tenant->name}: " . $e->getMessage());
                $failed++;
            }

            $bar->advance();
        }

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

        $this->info("Successful: {$successful}");
        if ($failed > 0) {
            $this->error("Failed: {$failed}");
        }

        return $failed > 0 ? Command::FAILURE : Command::SUCCESS;
    }

    protected function migrateTenant(Tenant $tenant): void
    {
        // Switch to tenant database
        config(['database.connections.tenant.database' => $tenant->database]);
        DB::purge('tenant');
        DB::reconnect('tenant');

        // Run migration on tenant connection
        $command = $this->option('fresh') ? 'migrate:fresh' : 'migrate';

        Artisan::call($command, [
            '--database' => 'tenant',
            '--force' => $this->option('force'),
        ]);

        // Run seeders if requested
        if ($this->option('seed')) {
            Artisan::call('db:seed', [
                '--database' => 'tenant',
                '--force' => $this->option('force'),
            ]);
        }
    }
}

Data Import with Validation

<?php

namespace App\Console\Commands;

use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use League\Csv\Reader;

class ImportProducts extends Command
{
    protected $signature = 'import:products
        {file : Path to CSV file}
        {--validate-only : Only validate, don\'t import}
        {--batch-size=100 : Number of records per batch}';

    protected $description = 'Import products from CSV file';

    public function handle(): int
    {
        $filePath = $this->argument('file');

        if (!file_exists($filePath)) {
            $this->error("File not found: {$filePath}");
            return Command::FAILURE;
        }

        $csv = Reader::createFromPath($filePath, 'r');
        $csv->setHeaderOffset(0);

        $records = collect(iterator_to_array($csv->getRecords()));
        $this->info("Found {$records->count()} records to process");

        // Validate all records first
        $validationErrors = $this->validateRecords($records);

        if ($validationErrors->isNotEmpty()) {
            $this->error("Validation failed for {$validationErrors->count()} records:");
            $this->table(
                ['Row', 'Field', 'Error'],
                $validationErrors->take(10)
            );

            if (!$this->confirm('Continue with valid records only?')) {
                return Command::FAILURE;
            }
        }

        if ($this->option('validate-only')) {
            $this->info('Validation complete. Run without --validate-only to import.');
            return Command::SUCCESS;
        }

        // Import valid records
        $validRecords = $records->whereNotIn('_row', $validationErrors->pluck('row'));
        $this->importRecords($validRecords);

        return Command::SUCCESS;
    }

    protected function validateRecords($records)
    {
        $errors = collect();

        foreach ($records as $index => $record) {
            $validator = Validator::make($record, [
                'sku' => 'required|unique:products,sku',
                'name' => 'required|string|max:255',
                'price' => 'required|numeric|min:0',
                'stock' => 'required|integer|min:0',
            ]);

            if ($validator->fails()) {
                foreach ($validator->errors()->messages() as $field => $messages) {
                    foreach ($messages as $message) {
                        $errors->push([
                            'row' => $index + 2, // +2 for header and 0-index
                            'field' => $field,
                            'error' => $message,
                        ]);
                    }
                }
            }
        }

        return $errors;
    }

    protected function importRecords($records): void
    {
        $batchSize = (int) $this->option('batch-size');
        $bar = $this->output->createProgressBar($records->count());
        $bar->start();

        $records->chunk($batchSize)->each(function ($chunk) use ($bar) {
            $products = $chunk->map(fn($record) => [
                'sku' => $record['sku'],
                'name' => $record['name'],
                'price' => $record['price'],
                'stock' => $record['stock'],
                'created_at' => now(),
                'updated_at' => now(),
            ])->toArray();

            Product::insert($products);
            $bar->advance($chunk->count());
        });

        $bar->finish();
        $this->newLine();
        $this->info("Imported {$records->count()} products successfully!");
    }
}

Learn about Laravel performance optimisation for handling large datasets.

Automated Backup Command

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Process;

class BackupDatabase extends Command
{
    protected $signature = 'backup:database
        {--compress : Compress backup file}
        {--storage=s3 : Storage disk for backup}
        {--keep=7 : Days to keep old backups}';

    protected $description = 'Create database backup and upload to cloud storage';

    public function handle(): int
    {
        $this->info('Starting database backup...');

        // Generate backup filename
        $filename = 'backup-' . now()->format('Y-m-d-His') . '.sql';
        $filepath = storage_path("app/backups/{$filename}");

        // Ensure backup directory exists
        if (!is_dir(dirname($filepath))) {
            mkdir(dirname($filepath), 0755, true);
        }

        // Create database backup
        $this->info('Creating database dump...');
        if (!$this->createDump($filepath)) {
            $this->error('Failed to create database dump');
            return Command::FAILURE;
        }

        // Compress if requested
        if ($this->option('compress')) {
            $this->info('Compressing backup...');
            $this->compressFile($filepath);
            $filepath .= '.gz';
            $filename .= '.gz';
        }

        // Upload to cloud storage
        $disk = $this->option('storage');
        $this->info("Uploading to {$disk} storage...");

        Storage::disk($disk)->putFileAs(
            'database-backups',
            $filepath,
            $filename
        );

        // Clean up local file
        unlink($filepath);

        // Delete old backups
        $this->cleanOldBackups($disk);

        $this->info('Backup completed successfully!');
        $this->info("File: {$filename}");
        $this->info("Size: " . $this->formatBytes(Storage::disk($disk)->size("database-backups/{$filename}")));

        return Command::SUCCESS;
    }

    protected function createDump(string $filepath): bool
    {
        $command = sprintf(
            'mysqldump --user=%s --password=%s --host=%s %s > %s',
            config('database.connections.mysql.username'),
            config('database.connections.mysql.password'),
            config('database.connections.mysql.host'),
            config('database.connections.mysql.database'),
            $filepath
        );

        $process = Process::fromShellCommandline($command);
        $process->setTimeout(3600); // 1 hour timeout
        $process->run();

        return $process->isSuccessful();
    }

    protected function compressFile(string $filepath): void
    {
        $process = new Process(['gzip', $filepath]);
        $process->run();
    }

    protected function cleanOldBackups(string $disk): void
    {
        $daysToKeep = (int) $this->option('keep');
        $cutoffDate = now()->subDays($daysToKeep);

        $files = Storage::disk($disk)->files('database-backups');

        foreach ($files as $file) {
            $lastModified = Storage::disk($disk)->lastModified($file);

            if ($lastModified < $cutoffDate->timestamp) {
                Storage::disk($disk)->delete($file);
                $this->line("Deleted old backup: " . basename($file));
            }
        }
    }

    protected function formatBytes(int $bytes): string
    {
        $units = ['B', 'KB', 'MB', 'GB'];
        $bytes = max($bytes, 0);
        $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
        $pow = min($pow, count($units) - 1);
        $bytes /= (1 << (10 * $pow));

        return round($bytes, 2) . ' ' . $units[$pow];
    }
}

Review Laravel API documentation patterns for backup API endpoints.

Command Scheduling

Basic Scheduling

Define scheduled commands in app/Console/Kernel.php:

<?php

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    protected function schedule(Schedule $schedule): void
    {
        // Run daily at 2 AM
        $schedule->command('backup:database')->daily()->at('02:00');

        // Run every hour
        $schedule->command('cache:clear')->hourly();

        // Run every 5 minutes
        $schedule->command('queue:monitor')->everyFiveMinutes();

        // Run on weekdays only
        $schedule->command('report:daily')
            ->weekdays()
            ->at('09:00');

        // Run on Mondays at 8 AM
        $schedule->command('report:weekly')
            ->mondays()
            ->at('08:00');

        // Custom cron expression
        $schedule->command('cleanup:temp')
            ->cron('*/15 * * * *'); // Every 15 minutes
    }
}

Scheduling Frequencies

$schedule->command('command')
    ->everyMinute()           // Every minute
    ->everyTwoMinutes()       // Every 2 minutes
    ->everyFiveMinutes()      // Every 5 minutes
    ->everyTenMinutes()       // Every 10 minutes
    ->everyFifteenMinutes()   // Every 15 minutes
    ->everyThirtyMinutes()    // Every 30 minutes
    ->hourly()                // Every hour
    ->hourlyAt(15)            // 15 minutes past every hour
    ->everyTwoHours()         // Every 2 hours
    ->daily()                 // Daily at midnight
    ->dailyAt('13:00')        // Daily at 1 PM
    ->twiceDaily(1, 13)       // 1 AM and 1 PM
    ->weekly()                // Sunday at midnight
    ->weeklyOn(1, '8:00')     // Monday at 8 AM
    ->monthly()               // First day of month at midnight
    ->monthlyOn(15, '12:00')  // 15th of month at noon
    ->quarterly()             // First day of quarter
    ->yearly()                // First day of year
    ->timezone('Europe/London'); // Set timezone

Conditional Scheduling

$schedule->command('backup:database')
    ->daily()
    ->when(function () {
        // Only run on production
        return app()->environment('production');
    })
    ->skip(function () {
        // Skip if maintenance mode
        return app()->isDownForMaintenance();
    });

Output and Monitoring

$schedule->command('report:generate')
    ->daily()
    ->sendOutputTo(storage_path('logs/reports.log'))
    ->emailOutputTo('admin@example.com')
    ->emailOutputOnFailure('errors@example.com')
    ->onSuccess(function () {
        // Task succeeded
    })
    ->onFailure(function () {
        // Task failed
    })
    ->before(function () {
        // Before task runs
    })
    ->after(function () {
        // After task completes
    });

Preventing Overlaps

$schedule->command('process:orders')
    ->everyMinute()
    ->withoutOverlapping()          // Don't run if previous instance still running
    ->withoutOverlapping(10);       // Expire lock after 10 minutes

Running One Instance

$schedule->command('sync:external-api')
    ->everyFiveMinutes()
    ->onOneServer()  // Only run on one server in cluster
    ->runInBackground(); // Run in background

Setting Up Cron

Add this single cron entry to run Laravel’s scheduler:

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

Test scheduling without cron:

php artisan schedule:run
php artisan schedule:list  # View all scheduled tasks
php artisan schedule:test  # Test a specific scheduled task

Testing Artisan Commands

Basic Command Test

<?php

namespace Tests\Feature\Console;

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

class GenerateReportTest extends TestCase
{
    use RefreshDatabase;

    public function test_command_generates_report_successfully()
    {
        User::factory()->count(10)->create();

        $this->artisan('report:generate')
            ->expectsOutput('Report generated successfully!')
            ->assertExitCode(0);
    }

    public function test_command_handles_no_data()
    {
        $this->artisan('report:generate')
            ->expectsOutput('No data available')
            ->assertExitCode(0);
    }
}

Testing with Arguments and Options

public function test_command_accepts_arguments()
{
    $this->artisan('user:create', [
        'name' => 'John Smith',
        'email' => 'john@example.com',
        '--admin' => true,
    ])
    ->expectsOutput('User John Smith created successfully!')
    ->assertExitCode(0);

    $this->assertDatabaseHas('users', [
        'name' => 'John Smith',
        'email' => 'john@example.com',
        'is_admin' => true,
    ]);
}

Testing User Interaction

public function test_command_prompts_for_confirmation()
{
    $this->artisan('database:wipe')
        ->expectsQuestion('Are you sure?', 'yes')
        ->expectsOutput('Database wiped successfully!')
        ->assertExitCode(0);
}

public function test_command_handles_cancellation()
{
    $this->artisan('database:wipe')
        ->expectsQuestion('Are you sure?', 'no')
        ->expectsOutput('Operation cancelled.')
        ->assertExitCode(0);
}

Testing Command Output

public function test_command_displays_table()
{
    User::factory()->count(3)->create();

    $this->artisan('users:list')
        ->expectsTable(
            ['ID', 'Name', 'Email'],
            User::all(['id', 'name', 'email'])->toArray()
        )
        ->assertExitCode(0);
}

Learn about Laravel testing strategies for comprehensive test coverage.

Advanced Patterns

Calling Commands from Code

// Within a controller or service
use Illuminate\Support\Facades\Artisan;

// Call synchronously
$exitCode = Artisan::call('cache:clear');

// Call with arguments
Artisan::call('user:create', [
    'name' => 'John',
    'email' => 'john@example.com',
    '--admin' => true
]);

// Get command output
$output = Artisan::output();

// Call asynchronously (queue)
Artisan::queue('backup:database', [
    '--compress' => true
]);

// Call in background
Artisan::queue('import:products', ['file' => $path])
    ->onQueue('imports');

Calling Commands from Other Commands

public function handle(): int
{
    $this->info('Starting deployment...');

    // Call another command
    $this->call('cache:clear');

    // Call with arguments
    $this->call('migrate', [
        '--force' => true
    ]);

    // Call silently (no output)
    $this->callSilently('optimize');

    $this->info('Deployment complete!');

    return Command::SUCCESS;
}

Dependency Injection

<?php

namespace App\Console\Commands;

use App\Services\ReportGenerator;
use App\Services\EmailService;
use Illuminate\Console\Command;

class GenerateReport extends Command
{
    protected $signature = 'report:generate';

    public function __construct(
        private ReportGenerator $reportGenerator,
        private EmailService $emailService
    ) {
        parent::__construct();
    }

    public function handle(): int
    {
        $report = $this->reportGenerator->generate();
        $this->emailService->send($report);

        return Command::SUCCESS;
    }
}

Long-Running Commands with Signals

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class ProcessQueue extends Command
{
    protected $signature = 'queue:process';
    private bool $shouldContinue = true;

    public function handle(): int
    {
        // Register signal handlers
        pcntl_async_signals(true);

        pcntl_signal(SIGTERM, function () {
            $this->info('Received SIGTERM. Shutting down gracefully...');
            $this->shouldContinue = false;
        });

        pcntl_signal(SIGINT, function () {
            $this->info('Received SIGINT. Shutting down gracefully...');
            $this->shouldContinue = false;
        });

        $this->info('Started processing queue...');

        while ($this->shouldContinue) {
            // Process queue items
            $this->processNextJob();

            sleep(1); // Prevent tight loop
        }

        $this->info('Shutdown complete.');

        return Command::SUCCESS;
    }

    private function processNextJob(): void
    {
        // Your job processing logic
    }
}

Best Practices

Naming Conventions

Use clear, hierarchical naming:

// Good
protected $signature = 'user:create';
protected $signature = 'user:delete';
protected $signature = 'user:export';
protected $signature = 'report:sales';
protected $signature = 'report:inventory';
protected $signature = 'cache:warm';
protected $signature = 'backup:database';

// Avoid
protected $signature = 'createUser';
protected $signature = 'doBackup';
protected $signature = 'run-reports';

Error Handling

public function handle(): int
{
    try {
        $this->info('Starting process...');

        // Risky operation
        $this->performOperation();

        $this->info('Process completed successfully!');
        return Command::SUCCESS;

    } catch (\Exception $e) {
        $this->error('Process failed: ' . $e->getMessage());

        // Log for debugging
        \Log::error('Command failed', [
            'command' => $this->signature,
            'exception' => $e,
        ]);

        return Command::FAILURE;
    }
}

Logging

use Illuminate\Support\Facades\Log;

public function handle(): int
{
    Log::info('Command started', [
        'command' => $this->signature,
        'arguments' => $this->arguments(),
        'options' => $this->options(),
    ]);

    // Your logic

    Log::info('Command completed', [
        'duration' => $duration,
        'records_processed' => $count,
    ]);

    return Command::SUCCESS;
}

Memory Management

public function handle(): int
{
    User::chunk(100, function ($users) {
        foreach ($users as $user) {
            $this->processUser($user);
        }

        // Free memory
        gc_collect_cycles();
    });

    return Command::SUCCESS;
}

Database Transactions

use Illuminate\Support\Facades\DB;

public function handle(): int
{
    DB::transaction(function () {
        // All operations succeed or all fail
        $this->createRecords();
        $this->updateRelations();
        $this->sendNotifications();
    });

    return Command::SUCCESS;
}

Key Takeaways

  • Structure commands logically with clear namespacing (e.g., user:create, report:generate)
  • Provide helpful feedback using info, warnings, errors, progress bars, and tables
  • Make commands flexible with arguments and options for different use cases
  • Test thoroughly using Laravel’s command testing helpers
  • Handle errors gracefully with try-catch blocks and appropriate exit codes
  • Use dependency injection for services rather than instantiating directly
  • Schedule carefully with appropriate frequencies and overlap prevention
  • Monitor production commands with logging, notifications, and alerting
  • Optimise for memory when processing large datasets using chunking
  • Document thoroughly with clear descriptions and help text

Complete Series Navigation

Master Laravel development with the complete series:

  1. Advanced Eloquent Relationships – Polymorphic relations and complex queries
  2. Custom Artisan Commands (this post) – CLI automation and task runners
  3. Advanced Middleware Patterns – Request processing and authentication
  4. Event-Driven Architecture – Events, listeners, and observers
  5. Advanced Testing Strategies – Comprehensive test coverage
  6. Performance Optimisation – Caching, query optimisation, profiling
  7. API Design and Documentation – RESTful APIs and OpenAPI
  8. Deployment and DevOps – CI/CD and production workflows

For AI-powered Laravel development, explore:

Need custom Laravel command development? Get in touch to discuss automation strategies for your application.