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) orCommand::FAILURE(1). Returningvoidornulldefaults 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.













