Tech Verse Logo
Enable dark mode
Writing Production-Ready Laravel Artisan Commands

Writing Production-Ready Laravel Artisan Commands

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

Artisan commands are the backbone of scheduled background tasks, data migrations, and system maintenance scripts in modern Laravel apps. Yet many codebases treat console commands like throwaway scripts. You end up with 300-line handle() methods, missing exit codes, runaway memory consumption, and zero test coverage.

Building a laravel artisan command that stays maintainable requires applying the exact same engineering standards you apply to HTTP controllers and queue jobs. Let's look at how to build commands in PHP 8.3 and Laravel 12 that won't break at 3 AM.

Expressive Signatures and Strict Input Types

Command signatures are a domain-specific language for your CLI interface. Too many developers write a bare signature like protected $signature = 'app:prune {days}'; and move on. Six months later, a developer runs the command without arguments, or passes a string into what should be an integer, and the script throws an unhandled exception halfway through mutating data.

Laravel's console signature parser supports default values, optional options, arrays, and inline description text. You can define exact requirements directly in the signature string so that php artisan help gives accurate documentation out of the box.

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Services\SubscriptionPruner;

class PruneExpiredSubscriptions extends Command
{
    protected $signature = 'subscriptions:prune
                            {--days=30 : The number of inactive days before pruning}
                            {--dry-run : Simulate the process without removing data}
                            {--chunk=500 : Number of records to evaluate per batch}';

    protected $description = 'Prune subscriptions that have remained inactive past the retention limit';

    public function handle(SubscriptionPruner $pruner): int
    {
        $days = (int) $this->option('days');
        $dryRun = (bool) $this->option('dry-run');
        $chunkSize = (int) $this->option('chunk');

        if ($days < 1) {
            $this->error('The --days option must be greater than 0.');
            return Command::FAILURE;
        }

        $processed = $pruner->execute($days, $chunkSize, $dryRun);

        $this->info("Successfully processed {$processed} expired subscriptions.");
        return Command::SUCCESS;
    }
}

Always cast signature inputs explicitly inside your handler. Command options return string or boolean values by default, so casting (int) $this->option('days') guards your downstream services against type mismatch errors in PHP 8.3.

Concurrency Control with Isolation Locks

Scheduled commands run on a cadence, but data growth means a job that took 20 seconds last month might take 3 minutes today. If your task runs every minute via the Laravel scheduler, you will quickly end up with multiple processes modifying the exact same table rows simultaneously. Race conditions, deadlocks, and duplicate API calls follow immediately.

You don't need to manually construct Redis locks or manage file handles inside your business logic. In Laravel 12, implement the Isolatable contract or configure the command's isolation settings.

namespace App\Console\Commands;

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

class SyncSupplierInventory extends Command implements Isolatable
{
    protected $signature = 'inventory:sync {supplier_id}';
    protected $description = 'Fetch fresh stock levels from an external supplier API';

    public bool $isolated = true;

    public function isolationLockExpiresAt()
    {
        return now()->addMinutes(10);
    }

    public function handle(InventorySynchronizer $synchronizer): int
    {
        $supplierId = (int) $this->argument('supplier_id');
        $synchronizer->sync($supplierId);

        return Command::SUCCESS;
    }
}

When a second execution triggers while the first is active, Laravel uses your default cache store to register an atomic lock. The duplicate process exits cleanly with status 1 without executing a single line of your handler code.

Memory-Safe Progress Bars for Large Batches

A classic CLI bug is combining User::all() or User::get() with $this->withProgressBar(). Pulling 50,000 Eloquent models into memory at once creates an exponential spike in RAM consumption. On production server instances capped at 128MB or 256MB of PHP memory, the process crashes with a fatal memory exhaustion error.

Instead of fetching collections, combine low-overhead query generators like lazyById() with manual progress bar increments. Memory usage stays flat regardless of whether you process 500 records or 5,000,000 records.

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;

class SendQuarterlyDigest extends Command
{
    protected $signature = 'digest:send';
    protected $description = 'Send performance summaries to active subscribers';

    public function handle(): int
    {
        $query = User::query()->where('active', true);
        $totalRecords = $query->count();

        if ($totalRecords === 0) {
            $this->info('No active users found to process.');
            return Command::SUCCESS;
        }

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

        $query->lazyById(250)->each(function (User $user) use ($bar) {
            // Execute business task for user
            $bar->advance();
        });

        $bar->finish();
        $this->newLine();
        $this->info('Quarterly digests dispatched.');

        return Command::SUCCESS;
    }
}

Using lazyById() orders records by primary key and fetches chunked sets under the hood using indexed SQL clauses (WHERE id > ? ORDER BY id ASC LIMIT 250). Your script runs with a flat 18MB memory footprint from start to finish.

Decoupling Command Logic for Testability

Treat console commands like HTTP controllers. Their sole job is to accept input, call a dedicated domain service or action, and output the result. Placing direct database updates, HTTP calls, or calculations inside handle() forces you to mock global CLI state during tests.

When you delegate work to injectables, testing your laravel artisan command becomes concise and predictable using Laravel's console testing helpers.

// tests/Feature/Commands/PruneExpiredSubscriptionsTest.php

use App\Services\SubscriptionPruner;

it('prunes subscriptions and displays correct output', function () {
    $this->mock(SubscriptionPruner::class)
        ->shouldReceive('execute')
        ->once()
        ->with(15, 500, true)
        ->andReturn(12);

    $this->artisan('subscriptions:prune', ['--days' => 15, '--dry-run' => true])
        ->expectsOutputToContain('Successfully processed 12 expired subscriptions.')
        ->assertExitCode(0);
});

it('rejects invalid day thresholds', function () {
    $this->artisan('subscriptions:prune', ['--days' => 0])
        ->expectsOutputToContain('The --days option must be greater than 0.')
        ->assertExitCode(1);
});

Production Gotchas to Keep in Mind

  • Missing Exit Codes: Always return Command::SUCCESS (0) or Command::FAILURE (1). Returning void or null defaults to code 0, which tricks CI/CD deployment pipelines into believing a failed job succeeded.

  • Uncaught Exceptions in Scheduler: Uncaught exceptions inside background commands halt command execution. Wrap volatile operations in try-catch blocks and log failures through Log::error() before returning a failure exit code.

  • Long-Running Event Listeners: If your command runs for minutes, remember that events dispatched during execution run in the same memory context unless sent to a queue.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Laravel Signed URLs and One-Time Download Links

    Laravel Signed URLs and One-Time Download Links

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Building Honest Health Check Endpoints in Laravel

    Building Honest Health Check Endpoints in Laravel

    Writing Production-Ready Laravel Artisan Commands

    Writing Production-Ready Laravel Artisan Commands

    Testing Mail in Laravel: Mailables and Assertions

    Testing Mail in Laravel: Mailables and Assertions

    Solving Low-Priority Queue Starvation in Laravel

    Solving Low-Priority Queue Starvation in Laravel

    Realistic Laravel Seeders with States and Relations

    Realistic Laravel Seeders with States and Relations

    Fixing Laravel Broadcasting Auth and 403 Errors

    Fixing Laravel Broadcasting Auth and 403 Errors

    Laravel Multi Tenancy: Single vs Multi Database

    Laravel Multi Tenancy: Single vs Multi Database