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 Artisan Commands Matter (Beyond Convenience)
Custom commands are one of the fastest ways to turn manual operational work into repeatable, auditable tooling. The benefit is not just speed. It is consistency.
Good commands are:
- Idempotent: safe to run twice without breaking state
- Observable: they log what changed and what did not
- Predictable: they return meaningful exit codes for CI/CD
- Operationally friendly: they support
--dry-run,--force, and locking
Design Rationale and Trade-offs
Prefer “business outcomes” over “technical actions”
A command named orders:rebuild-index is clearer than orders:sync-elastic. It describes intent, not implementation. That makes your tooling resilient when you swap vendors later.
Treat commands like APIs
Validate inputs, fail loudly, and keep output stable. Your future self will wire these into deploy pipelines, cron jobs, or support scripts.
Locking prevents expensive mistakes
Any command that touches money, emails, imports, or external APIs should implement a simple lock (cache or DB). This stops double-runs when cron overlaps or someone hits enter twice.
Scheduling in Laravel 11 and Newer
If you are using the Laravel 11+ skeleton, scheduled tasks are typically defined in routes/console.php using the Schedule facade, rather than a separate Console Kernel. This is a structural change, not a feature change. The goals are simpler configuration and fewer framework files.
Practical Patterns Worth Adding
--dry-runfor anything destructive- Chunked processing (
chunkById) for large datasets - Progress output for long-running tasks
- Structured logging for support visibility
- Queue offloading for work that does not need to block the command
FAQ
Should Artisan commands contain business logic?
Prefer to call a service or action class. Commands should orchestrate, validate inputs, and report results.
Should commands be queued?
Some should. If the work is heavy, dispatch jobs and make the command a launcher with good reporting.
How do I make commands safe in production?
Use --force for destructive actions, add locking, and log a summary of changes.
Key Takeaways
- Build commands you can trust at 3am.
- Make them idempotent, observable, and safe by default.
- Keep business logic in services, not inside the command class.
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" [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
{
$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', '[email protected]');
// 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('[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() // 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' => '[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()
{
$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' => '[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
{
// 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:
- Advanced Eloquent Relationships – Polymorphic relations and complex queries
- Custom Artisan Commands (this post) – CLI automation and task runners
- Advanced Middleware Patterns – Request processing and authentication
- Event-Driven Architecture – Events, listeners, and observers
- Advanced Testing Strategies – Comprehensive test coverage
- Performance Optimisation – Caching, query optimisation, profiling
- API Design and Documentation – RESTful APIs and OpenAPI
- Deployment and DevOps – CI/CD and production workflows
For AI-powered Laravel development, explore:
- AI Code Generation – Automated scaffolding
- Setting up MCP – AI context management
Need Laravel development help? I work with businesses across Chester and the North West. Learn more about my Laravel services or get in touch.