Laravel Mastery: Custom Artisan commands

Custom Artisan commands for imports, clean-ups and deployments: arguments, scheduling, locking, testing, and making destructive commands safe to run.

Updated 14 min read

An Artisan command is a PHP class with a signature and a handle() method. Running php artisan make:command puts one in app/Console/Commands, and Laravel picks it up from there without any registration.

Commands earn their place on work you’d otherwise do by hand: imports and exports, reports, clean-ups, backups and per-tenant maintenance. Written well, they turn a support runbook into one line you can schedule, call from a deploy pipeline or hand to a colleague.

This guide builds them up in order: arguments and options, output and prompts, three production commands, scheduling and testing. It closes with the habits that make a command safe to run twice, or at 3am. The examples assume Laravel 11 or later, which schedules tasks in routes/console.php. Where older versions differ, I say so.

Where commands pay off

  • Development: seeding realistic test data, warming caches, scaffolding code
  • Operations: imports and exports, reports, clean-ups, health checks
  • Multi-tenant systems: per-tenant migrations, cache management and bulk operations
  • Integrations: API synchronisation, payment reconciliation, search index rebuilds

Keep the business logic in a service or action class. The command validates its input, calls the service and reports the result, so the same logic also works from a controller, a queued job or a test.

For reacting to what a command changes, see event-driven architecture. For the request side of the same application, see Laravel middleware patterns.

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: the command name, with colons for grouping
  • {argument}: a required positional argument
  • {argument?}: an optional argument
  • {argument=default}: an argument with a default value
  • {--option}: a boolean option, or flag
  • {--option=}: an option that takes a value
  • {--option=default}: an option with a default value

Return codes

Return an exit code that says what happened:

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

Required arguments

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

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

    User::create([
        'name' => $name,
        'email' => $email,
        'password' => Hash::make(Str::random(16)), // They set their own with a password reset
        'is_admin' => $this->option('admin'),
    ]);

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

    return Command::SUCCESS;
}

Usage:

php artisan user:create "John Smith" [email protected]

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
{
    // One query, and ids that don't exist are simply left out
    $users = User::findMany($this->argument('users'));

    foreach ($users as $user) {
        Mail::to($user)->send(new WelcomeEmail());
    }

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

    return Command::SUCCESS;
}

Usage:

php artisan email:send 1 2 3 4 5

Boolean options (flags)

// A name of its own, so it doesn't clash with the built-in cache:clear
protected $signature = 'app:flush-cache {--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');

    if (!in_array($format, ['csv', 'json', 'xml'], true)) {
        $this->error("Unsupported format: {$format}");
        return Command::FAILURE;
    }

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

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

    return Command::SUCCESS;
}

Check option values before doing any work, and return FAILURE when one is wrong. A typo in a cron line should fail loudly rather than report success.

Input descriptions

Describe each argument and option in the signature:

protected $signature = 'deploy:application
    {environment : The environment to deploy (staging|production)}
    {--branch=main : 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.

Output and prompts

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

Show rows of data as a table:

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', '[email protected]');

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

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

Three production commands

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);

        // Keyed by offset in the file: the header is 0, so the first record is 1
        $records = collect(iterator_to_array($csv->getRecords()));
        $this->info("Found {$records->count()} records to process");

        // Validate all records first
        $errors = $this->validateRecords($records);
        $failed = $errors->pluck('offset')->unique();

        if ($failed->isNotEmpty()) {
            $this->error("Validation failed for {$failed->count()} records:");
            $this->table(
                ['Row', 'Field', 'Error'],
                // Offset 1 is row 2 of the spreadsheet, under the header
                $errors->take(10)->map(fn ($e) => [$e['offset'] + 1, $e['field'], $e['error']])
            );
        }

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

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

        // Import the records that passed
        $this->importRecords($records->except($failed->all()));

        return Command::SUCCESS;
    }

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

        foreach ($records as $offset => $record) {
            $validator = Validator::make($record, [
                'sku' => 'required|unique:products,sku', // checks the database, not the rest of this file
                '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([
                            'offset' => $offset,
                            '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!");
    }
}

Two things to know before you run this on a real file. With --validate-only it returns FAILURE when any row is bad, so a pipeline can check a file before anyone imports it. And the unique rule only looks at the database. Two rows with the same SKU in one file both pass, and the second either fails on a unique index or becomes a duplicate. Check for duplicates within the file as well, or use upsert() keyed on sku.

For larger files, see Laravel performance optimisation.

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
    {
        $db = config('database.connections.mysql');

        // Credentials go in a private options file, not on the command line,
        // where anyone on the server can read them from the process list.
        // tempnam() creates the file readable by this user only.
        $credentials = tempnam(sys_get_temp_dir(), 'mysqldump');
        file_put_contents($credentials, sprintf(
            "[client]\nuser=\"%s\"\npassword=\"%s\"\nhost=\"%s\"\nport=%d\n",
            $db['username'], $db['password'], $db['host'], $db['port']
        ));

        try {
            // An argument list, not a shell string, so nothing is open to injection
            $process = new Process([
                'mysqldump',
                "--defaults-extra-file={$credentials}", // must be the first option
                '--single-transaction', // a consistent snapshot without locking InnoDB tables
                "--result-file={$filepath}",
                $db['database'],
            ]);
            $process->setTimeout(3600); // 1 hour timeout
            $process->run();

            return $process->isSuccessful();
        } finally {
            unlink($credentials);
        }
    }

    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];
    }
}

For most projects, spatie/laravel-backup already does this, with retention rules and failure notifications. Write your own when you need something it doesn’t do, and test the restore as well as the backup.

Command scheduling

Fig. 01 Schedule
The tappets pushed down are the hours that run a job. Point at an hour to raise its tappet, and the read-out says whether a job runs then.

Basic scheduling

From Laravel 11, scheduled commands live in routes/console.php, using the Schedule facade:

<?php

use Illuminate\Support\Facades\Schedule;

// Daily at 2am
Schedule::command('backup:database')->dailyAt('02:00');

// Daily, deleting old records from models that use the Prunable trait
Schedule::command('model:prune')->daily();

// Every minute, firing a QueueBusy event when the queue passes 100 jobs
Schedule::command('queue:monitor redis:default --max=100')->everyMinute();

// Weekdays at 9am
Schedule::command('report:daily')
    ->weekdays()
    ->at('09:00');

// Mondays at 8am
Schedule::command('report:weekly')
    ->mondays()
    ->at('08:00');

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

On Laravel 10 and earlier, the same calls go in the schedule() method of app/Console/Kernel.php, written as $schedule->command(...).

Scheduling frequencies

// One frequency per task; they're listed together here for reference
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()
    ->environments(['production'])              // Only on production
    ->when(fn () => config('backup.enabled'));   // Only while switched on

Scheduled tasks already skip themselves while the application is in maintenance mode, so there’s no need to check for it. Add evenInMaintenanceMode() to a task that must keep running, a backup for example.

Output and monitoring

Schedule::command('report:generate')
    ->daily()
    ->sendOutputTo(storage_path('logs/reports.log'))
    ->emailOutputTo('[email protected]')
    ->emailOutputOnFailure('[email protected]')
    ->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(10); // Skip a run while the last one holds the lock, for up to 10 minutes

Without an argument, the lock lasts 24 hours. If the server dies mid-run, a stale lock can stop the task for a day, so set an expiry a little longer than the task’s longest run.

Running one instance

Schedule::command('sync:external-api')
    ->everyFiveMinutes()
    ->onOneServer()      // Only one server in the cluster runs it
    ->runInBackground(); // Don't hold up the tasks scheduled after it

onOneServer() needs a default cache that every server shares: database, memcached, DynamoDB or Redis.

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   # Run whatever is due right now, once
php artisan schedule:list  # View all scheduled tasks and when they next run
php artisan schedule:test  # Pick a scheduled task and run it now
php artisan schedule:work  # Run the scheduler in the foreground, for local development

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' => '[email protected]',
        '--admin' => true,
    ])
    ->expectsOutput('User John Smith created successfully!')
    ->assertExitCode(0);

    $this->assertDatabaseHas('users', [
        'name' => 'John Smith',
        'email' => '[email protected]',
        'is_admin' => true,
    ]);
}

Testing user interaction

public function test_command_prompts_for_confirmation()
{
    // expectsConfirmation() answers a confirm(); expectsQuestion() is for ask()
    $this->artisan('database:wipe')
        ->expectsConfirmation('Are you sure?', 'yes')
        ->expectsOutput('Database wiped successfully!')
        ->assertExitCode(0);
}

public function test_command_handles_cancellation()
{
    $this->artisan('database:wipe')
        ->expectsConfirmation('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);
}

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' => '[email protected]',
    '--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
    {
        // Finish the current job, then stop. Needs the pcntl extension, so not on Windows.
        $this->trap([SIGTERM, SIGINT], function () {
            $this->info('Shutting down after the current job...');
            $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
    }
}

Making a command safe to run

A command you can trust at 3am has four properties:

  • Idempotent: running it twice leaves the same state as running it once
  • Observable: it logs what it changed, and what it skipped
  • Honest about failure: it returns Command::FAILURE, so cron monitoring and CI can see it
  • Careful by default: destructive work needs --force, and --dry-run shows what would happen

Locking stops a second copy starting while the first is still running, whether cron overlapped or someone pressed enter twice. Scheduled tasks get this from withoutOverlapping(). For commands run by hand, implement the Isolatable interface:

<?php

namespace App\Console\Commands;

use App\Services\PaymentReconciler;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;

class ReconcilePayments extends Command implements Isolatable
{
    protected $signature = 'payments:reconcile {--dry-run}';

    protected $description = 'Match payments against the provider and fix their status';

    public function handle(PaymentReconciler $reconciler): int
    {
        $dryRun = $this->option('dry-run');
        $changed = $reconciler->reconcile(dryRun: $dryRun);

        $this->info(($dryRun ? 'Would update' : 'Updated') . " {$changed} payments");

        return Command::SUCCESS;
    }
}

Laravel then adds an --isolated option. Run php artisan payments:reconcile --isolated, and a second copy does nothing while the first holds the lock. It still exits with a success code unless you ask for another, as in --isolated=1. The lock lives in your default cache, so servers that run the command must share it.

Best practices

Naming conventions

Name the outcome rather than the mechanism. orders:rebuild-index still makes sense after you change search provider, and orders:sync-elastic doesn’t. Group commands under a namespace:

// 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->getName(),
            'exception' => $e,
        ]);

        return Command::FAILURE;
    }
}

Logging

use Illuminate\Support\Facades\Log;

public function handle(): int
{
    $started = microtime(true);

    Log::info('Command started', [
        'command' => $this->getName(),
        'arguments' => $this->arguments(),
        'options' => $this->options(),
    ]);

    $count = $this->processRecords();

    Log::info('Command completed', [
        'command' => $this->getName(),
        'duration' => round(microtime(true) - $started, 2),
        'records_processed' => $count,
    ]);

    return Command::SUCCESS;
}

Memory management

public function handle(): int
{
    // Pages on the primary key, so updating rows inside the loop can't make it skip any
    User::chunkById(100, function ($users) {
        foreach ($users as $user) {
            $this->processUser($user);
        }
    });

    return Command::SUCCESS;
}

If you’d rather handle one model at a time, foreach (User::lazyById(100) as $user) does the same batching behind a plain loop.

Database transactions

use Illuminate\Support\Facades\DB;

public function handle(): int
{
    // All the writes succeed, or none do
    $records = DB::transaction(function () {
        $records = $this->createRecords();
        $this->updateRelations($records);

        return $records;
    });

    // Notify after the commit, so nobody hears about records that were rolled back
    $this->sendNotifications($records);

    return Command::SUCCESS;
}

Key takeaways

  • Name commands by outcome with clear namespacing (user:create, report:sales), and keep business logic in services
  • Return honest exit codes, so cron monitoring and CI notice failures
  • Make destructive commands careful with --force, --dry-run and confirmations
  • Lock what must not run twice, with withoutOverlapping() on the schedule and Isolatable for manual runs
  • Schedule in routes/console.php on Laravel 11 and later, and check it with schedule:list
  • Test commands with artisan(), expectsConfirmation() and expectsTable()
  • Process large datasets in batches with chunkById() or lazyById()

The Laravel Mastery 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, API versioning and authentication
  4. Event-driven architecture: events, listeners and broadcasting
  5. Performance optimisation: caching, query optimisation, profiling
  6. Deployment and DevOps: CI/CD and production workflows

On using AI with Laravel:

Scheduled commands like these take over imports and clean-ups that would otherwise be someone’s weekly admin. I write them as part of my Laravel development work.

Published , updated .

Part 2 of Laravel Mastery.

Related posts