Laravel Artisan Commands: Beyond the Basics - Creating Custom Commands

Published on November 26, 2025
Laravel Artisan CLI Commands PHP Console

Introduction to Custom Artisan Commands

While Laravel comes with many useful built-in Artisan commands, the real power comes when you create your own. Custom Artisan commands allow you to automate repetitive tasks, perform batch operations, and create powerful CLI tools for your application.

Why Create Custom Commands?

  • Automate repetitive development tasks
  • Perform database maintenance and cleanup
  • Generate custom file templates
  • Process queued jobs manually
  • Run scheduled data exports/imports
  • Execute complex business logic via CLI

Creating Your First Custom Command

Basic Command Structure

Let's start with a simple example: a welcome command that greets users.

php artisan make:command WelcomeCommand

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

<?php
// app/Console/Commands/WelcomeCommand.php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class WelcomeCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'app:welcome-command';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Execute the console command.
     */
    public function handle()
    {
        $this->info('Welcome to Laravel Artisan Commands!');
    }
}

Registering the Command

Commands are automatically registered if they're in the app/Console/Commands directory and your console kernel is properly configured.

<?php
// app/Console/Kernel.php

namespace App\Console;

use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    /**
     * Register the commands for the application.
     */
    protected function commands(): void
    {
        $this->load(__DIR__.'/Commands');

        require base_path('routes/console.php');
    }
}

Now run your command:

php artisan app:welcome-command

Command Signatures and Arguments

Basic Signatures

<?php
// app/Console/Commands/GreetUserCommand.php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class GreetUserCommand extends Command
{
    protected $signature = 'greet:user {name}';
    protected $description = 'Greet a user by name';

    public function handle()
    {
        $name = $this->argument('name');
        $this->info("Hello, {$name}! Welcome to our application.");
    }
}

Usage:

php artisan greet:user John

Optional Arguments and Default Values

protected $signature = 'greet:user 
                        {name=World : The name of the user to greet}
                        {--formal : Use formal greeting}';

public function handle()
{
    $name = $this->argument('name');
    $formal = $this->option('formal');
    
    $greeting = $formal ? "Good day, {$name}." : "Hello, {$name}!";
    $this->info($greeting);
}

Usage:

php artisan greet:user John --formal
php artisan greet:user # Uses "World" as default

Array Arguments and Multiple Options

protected $signature = 'email:send 
                        {emails* : List of email addresses}
                        {--subject=Newsletter : Email subject}
                        {--queue : Whether to queue the emails}';

public function handle()
{
    $emails = $this->argument('emails');
    $subject = $this->option('subject');
    $shouldQueue = $this->option('queue');
    
    $this->info("Sending '{$subject}' to: " . implode(', ', $emails));
    
    if ($shouldQueue) {
        $this->info('Emails will be queued for sending.');
    }
}

Usage:

php artisan email:send user1@example.com user2@example.com --subject="Welcome" --queue

Real-World Command Examples

Database Maintenance Command

<?php
// app/Console/Commands/DatabaseMaintenanceCommand.php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Models\Log;
use App\Models\TempFile;

class DatabaseMaintenanceCommand extends Command
{
    protected $signature = 'db:maintenance 
                           {--days=30 : Clean records older than X days}
                           {--optimize : Optimize database tables}
                           {--backup : Create database backup}';
    
    protected $description = 'Perform database maintenance tasks';

    public function handle()
    {
        $this->info('Starting database maintenance...');
        
        $this->cleanOldRecords();
        $this->cleanTempFiles();
        
        if ($this->option('optimize')) {
            $this->optimizeTables();
        }
        
        if ($this->option('backup')) {
            $this->createBackup();
        }
        
        $this->info('Database maintenance completed!');
    }
    
    protected function cleanOldRecords()
    {
        $days = $this->option('days');
        $cutoffDate = now()->subDays($days);
        
        $this->info("Cleaning records older than {$days} days...");
        
        // Clean old logs
        $logCount = Log::where('created_at', '<', $cutoffDate)->delete();
        $this->info("Deleted {$logCount} old log records.");
        
        // Clean old sessions
        $sessionCount = DB::table('sessions')
                         ->where('last_activity', '<', $cutoffDate->timestamp)
                         ->delete();
        $this->info("Deleted {$sessionCount} old session records.");
    }
    
    protected function cleanTempFiles()
    {
        $tempFileCount = TempFile::where('created_at', '<', now()->subDay())->delete();
        $this->info("Deleted {$tempFileCount} temporary files.");
    }
    
    protected function optimizeTables()
    {
        $this->info('Optimizing database tables...');
        
        $tables = DB::select('SHOW TABLES');
        foreach ($tables as $table) {
            $tableName = reset($table);
            DB::statement("OPTIMIZE TABLE {$tableName}");
            $this->info("Optimized table: {$tableName}");
        }
    }
    
    protected function createBackup()
    {
        $this->info('Creating database backup...');
        
        $filename = 'backup-' . date('Y-m-d-H-i-s') . '.sql';
        $path = storage_path('app/backups/' . $filename);
        
        // Simple backup using mysqldump (adjust for your database)
        $command = sprintf(
            'mysqldump -u%s -p%s %s > %s',
            config('database.connections.mysql.username'),
            config('database.connections.mysql.password'),
            config('database.connections.mysql.database'),
            $path
        );
        
        exec($command, $output, $returnVar);
        
        if ($returnVar === 0) {
            $this->info("Backup created: {$filename}");
        } else {
            $this->error('Backup failed!');
        }
    }
}

User Management Command

<?php
// app/Console/Commands/UserManagerCommand.php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

class UserManagerCommand extends Command
{
    protected $signature = 'user:manage 
                           {action : create|list|delete|reset-password}
                           {--id= : User ID for specific actions}
                           {--name= : User name}
                           {--email= : User email}
                           {--role=user : User role}
                           {--password= : User password (auto-generated if empty)}';
    
    protected $description = 'Manage application users';

    public function handle()
    {
        $action = $this->argument('action');
        
        switch ($action) {
            case 'create':
                $this->createUser();
                break;
            case 'list':
                $this->listUsers();
                break;
            case 'delete':
                $this->deleteUser();
                break;
            case 'reset-password':
                $this->resetPassword();
                break;
            default:
                $this->error("Invalid action: {$action}");
                $this->info('Available actions: create, list, delete, reset-password');
        }
    }
    
    protected function createUser()
    {
        $name = $this->option('name') ?? $this->ask('Enter user name:');
        $email = $this->option('email') ?? $this->ask('Enter user email:');
        $role = $this->option('role');
        $password = $this->option('password') ?? Str::random(12);
        
        if (User::where('email', $email)->exists()) {
            $this->error("User with email {$email} already exists!");
            return;
        }
        
        $user = User::create([
            'name' => $name,
            'email' => $email,
            'password' => Hash::make($password),
            'role' => $role,
        ]);
        
        $this->info("User created successfully!");
        $this->table(
            ['ID', 'Name', 'Email', 'Role', 'Password'],
            [[$user->id, $user->name, $user->email, $user->role, $password]]
        );
    }
    
    protected function listUsers()
    {
        $users = User::select('id', 'name', 'email', 'role', 'created_at')
                    ->get()
                    ->toArray();
        
        $this->table(
            ['ID', 'Name', 'Email', 'Role', 'Created At'],
            $users
        );
        
        $this->info("Total users: " . count($users));
    }
    
    protected function deleteUser()
    {
        $userId = $this->option('id') ?? $this->ask('Enter user ID to delete:');
        
        $user = User::find($userId);
        
        if (!$user) {
            $this->error("User with ID {$userId} not found!");
            return;
        }
        
        if ($this->confirm("Are you sure you want to delete user: {$user->name} ({$user->email})?")) {
            $user->delete();
            $this->info("User deleted successfully!");
        }
    }
    
    protected function resetPassword()
    {
        $userId = $this->option('id') ?? $this->ask('Enter user ID:');
        $newPassword = $this->option('password') ?? Str::random(12);
        
        $user = User::find($userId);
        
        if (!$user) {
            $this->error("User with ID {$userId} not found!");
            return;
        }
        
        $user->update([
            'password' => Hash::make($newPassword)
        ]);
        
        $this->info("Password reset successfully!");
        $this->info("New password: {$newPassword}");
    }
}

Interactive Commands with User Input

Asking for Confirmation

public function handle()
{
    if ($this->confirm('Do you wish to continue?', true)) {
        $this->info('Continuing with the operation...');
        // Perform the operation
    } else {
        $this->info('Operation cancelled.');
    }
}

Multiple Choice Questions

public function handle()
{
    $environment = $this->choice(
        'Which environment are you deploying to?',
        ['local', 'staging', 'production'],
        0
    );
    
    $this->info("Deploying to: {$environment}");
    
    // Show progress bar for long operations
    $users = User::all();
    $bar = $this->output->createProgressBar(count($users));
    
    foreach ($users as $user) {
        // Process each user
        sleep(1); // Simulate work
        $bar->advance();
    }
    
    $bar->finish();
    $this->info("\nAll users processed!");
}

Table Output

protected function showSystemStats()
{
    $stats = [
        ['Users', User::count()],
        ['Active Users', User::where('last_login', '>', now()->subDay())->count()],
        ['Orders', \App\Models\Order::count()],
        ['Pending Orders', \App\Models\Order::where('status', 'pending')->count()],
    ];
    
    $this->table(['Metric', 'Count'], $stats);
}

Advanced Command Patterns

Command with Service Injection

<?php
// app/Console/Commands/GenerateReportsCommand.php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Services\ReportService;
use App\Services\ExportService;

class GenerateReportsCommand extends Command
{
    protected $signature = 'reports:generate 
                           {type : sales|users|products}
                           {--period=monthly : daily|weekly|monthly}
                           {--export= : csv|excel|pdf}';
    
    protected $description = 'Generate various application reports';
    
    protected $reportService;
    protected $exportService;
    
    public function __construct(ReportService $reportService, ExportService $exportService)
    {
        parent::__construct();
        $this->reportService = $reportService;
        $this->exportService = $exportService;
    }
    
    public function handle()
    {
        $type = $this->argument('type');
        $period = $this->option('period');
        $exportFormat = $this->option('export');
        
        $this->info("Generating {$type} report for {$period} period...");
        
        try {
            $data = $this->reportService->generate($type, $period);
            
            if ($exportFormat) {
                $filename = $this->exportService->export($data, $exportFormat, "{$type}-report");
                $this->info("Report exported to: {$filename}");
            } else {
                $this->displayReport($data, $type);
            }
            
        } catch (\Exception $e) {
            $this->error("Error generating report: " . $e->getMessage());
            return 1;
        }
        
        return 0;
    }
    
    protected function displayReport($data, $type)
    {
        switch ($type) {
            case 'sales':
                $this->displaySalesReport($data);
                break;
            case 'users':
                $this->displayUsersReport($data);
                break;
            case 'products':
                $this->displayProductsReport($data);
                break;
        }
    }
    
    protected function displaySalesReport($data)
    {
        $this->table(
            ['Date', 'Orders', 'Revenue', 'Average Order'],
            $data
        );
    }
    
    protected function displayUsersReport($data)
    {
        $this->table(
            ['Period', 'New Users', 'Active Users', 'Growth Rate'],
            $data
        );
    }
    
    protected function displayProductsReport($data)
    {
        $this->table(
            ['Product', 'Units Sold', 'Revenue', 'Stock Level'],
            $data
        );
    }
}

Queueable Commands

<?php
// app/Console/Commands/ProcessBigDataCommand.php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class ProcessBigDataCommand extends Command implements ShouldQueue
{
    use Dispatchable;
    
    protected $signature = 'data:process 
                           {file : Path to data file}
                           {--chunk=1000 : Records per chunk}
                           {--queue : Process in background}';
    
    protected $description = 'Process large data files in chunks';
    
    public function handle()
    {
        $file = $this->argument('file');
        $chunkSize = $this->option('chunk');
        $shouldQueue = $this->option('queue');
        
        if ($shouldQueue) {
            $this->info('Dispatching to queue...');
            self::dispatch($file, $chunkSize);
            $this->info('Job queued successfully!');
            return;
        }
        
        $this->processFile($file, $chunkSize);
    }
    
    protected function processFile($file, $chunkSize)
    {
        if (!file_exists($file)) {
            $this->error("File not found: {$file}");
            return;
        }
        
        $totalLines = $this->countLines($file);
        $this->info("Processing {$totalLines} records in chunks of {$chunkSize}...");
        
        $bar = $this->output->createProgressBar($totalLines);
        
        $handle = fopen($file, 'r');
        $header = fgetcsv($handle); // Skip header
        
        $chunk = [];
        $processed = 0;
        
        while (($row = fgetcsv($handle)) !== false) {
            $chunk[] = array_combine($header, $row);
            
            if (count($chunk) >= $chunkSize) {
                $this->processChunk($chunk);
                $processed += count($chunk);
                $bar->advance(count($chunk));
                $chunk = [];
            }
        }
        
        // Process remaining records
        if (!empty($chunk)) {
            $this->processChunk($chunk);
            $processed += count($chunk);
            $bar->advance(count($chunk));
        }
        
        fclose($handle);
        $bar->finish();
        
        $this->info("\nProcessed {$processed} records successfully!");
    }
    
    protected function countLines($file)
    {
        $lineCount = 0;
        $handle = fopen($file, 'r');
        
        while (!feof($handle)) {
            fgets($handle);
            $lineCount++;
        }
        
        fclose($handle);
        return $lineCount - 1; // Exclude header
    }
    
    protected function processChunk($chunk)
    {
        // Process your data chunk here
        foreach ($chunk as $record) {
            // Import/process each record
            // This could be saving to database, calling APIs, etc.
        }
    }
}

Testing Custom Commands

Basic Command Testing

<?php
// tests/Unit/Console/UserManagerCommandTest.php

namespace Tests\Unit\Console;

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

class UserManagerCommandTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_creation()
    {
        $this->artisan('user:manage create', [
            '--name' => 'Test User',
            '--email' => 'test@example.com',
            '--password' => 'secret123'
        ])
        ->expectsOutput('User created successfully!')
        ->assertExitCode(0);
        
        $this->assertDatabaseHas('users', [
            'name' => 'Test User',
            'email' => 'test@example.com'
        ]);
    }
    
    public function test_user_list()
    {
        User::factory()->count(3)->create();
        
        $this->artisan('user:manage list')
            ->expectsOutput('Total users: 3')
            ->assertExitCode(0);
    }
    
    public function test_user_deletion_with_confirmation()
    {
        $user = User::factory()->create();
        
        $this->artisan('user:manage delete', ['--id' => $user->id])
            ->expectsConfirmation("Are you sure you want to delete user: {$user->name} ({$user->email})?", 'yes')
            ->expectsOutput('User deleted successfully!')
            ->assertExitCode(0);
            
        $this->assertDatabaseMissing('users', ['id' => $user->id]);
    }
    
    public function test_password_reset()
    {
        $user = User::factory()->create();
        
        $this->artisan('user:manage reset-password', [
            '--id' => $user->id,
            '--password' => 'newpassword123'
        ])
        ->expectsOutput('Password reset successfully!')
        ->assertExitCode(0);
    }
}

Advanced Command Testing

<?php
// tests/Unit/Console/GenerateReportsCommandTest.php

namespace Tests\Unit\Console;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;

class GenerateReportsCommandTest extends TestCase
{
    use RefreshDatabase;

    public function test_sales_report_generation()
    {
        $this->artisan('reports:generate', ['type' => 'sales'])
            ->expectsOutput('Generating sales report for monthly period...')
            ->assertExitCode(0);
    }
    
    public function test_report_export_to_csv()
    {
        Storage::fake('exports');
        
        $this->artisan('reports:generate', [
            'type' => 'sales',
            '--export' => 'csv'
        ])
        ->expectsOutput('Report exported to:')
        ->assertExitCode(0);
        
        // Assert file was created
        Storage::disk('exports')->assertExists('sales-report-*.csv');
    }
    
    public function test_invalid_report_type()
    {
        $this->artisan('reports:generate', ['type' => 'invalid'])
            ->expectsOutput('Error generating report:')
            ->assertExitCode(1);
    }
    
    public function test_command_with_progress_bar()
    {
        $this->artisan('data:process', ['file' => 'test.csv'])
            ->expectsOutput('Processing')
            ->assertExitCode(0);
    }
}

Scheduling Custom Commands

<?php
// app/Console/Kernel.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)
    {
        // Daily database maintenance at 2:00 AM
        $schedule->command('db:maintenance --days=30 --optimize')
                 ->dailyAt('02:00')
                 ->environments(['production']);
        
        // Generate sales reports every Monday at 6:00 AM
        $schedule->command('reports:generate sales --period=weekly --export=csv')
                 ->weeklyOn(1, '06:00')
                 ->emailOutputTo('admin@example.com');
        
        // Process data files every hour
        $schedule->command('data:process /path/to/data.csv --queue')
                 ->hourly()
                 ->withoutOverlapping();
        
        // Clean temporary files every day
        $schedule->command('temp:clean')
                 ->daily();
        
        // Backup database every Sunday at 3:00 AM
        $schedule->command('db:maintenance --backup')
                 ->sundays()
                 ->at('03:00')
                 ->environments(['production']);
    }
    
    protected function commands()
    {
        $this->load(__DIR__.'/Commands');
        require base_path('routes/console.php');
    }
}

Command Best Practices

1. Use Descriptive Signatures

// Good
protected $signature = 'reports:generate-sales 
                       {period=monthly : Report period}
                       {--export= : Export format}';

// Avoid
protected $signature = 'gen:rep {p} {--e}';

2. Provide Helpful Descriptions

protected $description = 'Generate sales reports for specified period. 
                         Supports daily, weekly, and monthly periods.
                         Optional export to CSV, Excel, or PDF formats.';

3. Use Proper Exit Codes

public function handle()
{
    try {
        // Command logic
        $this->info('Command completed successfully!');
        return 0; // Success
    } catch (\Exception $e) {
        $this->error('Command failed: ' . $e->getMessage());
        return 1; // General error
    }
}

4. Handle Large Operations in Chunks

public function handle()
{
    $users = User::cursor(); // Uses generator to save memory
    
    $bar = $this->output->createProgressBar(User::count());
    
    foreach ($users as $user) {
        $this->processUser($user);
        $bar->advance();
    }
    
    $bar->finish();
}

5. Use Configuration for Default Values

// In config/commands.php
return [
    'db_maintenance' => [
        'cleanup_days' => 30,
        'optimize_tables' => true,
    ],
];

// In command
protected $signature = 'db:maintenance 
                       {--days=' . config('commands.db_maintenance.cleanup_days') . '}';

Common Interview Questions & Answers

  1. What are Laravel Artisan commands used for?
    Artisan commands provide a command-line interface for performing common tasks like database migrations, testing, generating boilerplate code, and custom application-specific operations.
  2. How do you create a custom Artisan command?
    Use php artisan make:command CommandName to generate the command class, then define the signature, description, and handle method logic.
  3. What's the difference between arguments and options?
    Arguments are required parameters ({name}), while options are optional ({--queue}). Arguments are positional, options are named.
  4. How can you schedule Artisan commands?
    Register commands in the app/Console/Kernel.php schedule method using Laravel's task scheduler with various frequency methods like daily(), hourly(), cron(), etc.
  5. How do you test Artisan commands?
    Use $this->artisan() in tests to execute commands and chain expectations like expectsOutput(), expectsQuestion(), assertExitCode().
  6. What are some common use cases for custom commands?
    • Database maintenance and cleanup
    • Data import/export operations
    • Report generation
    • System health checks
    • Batch processing of data
    • Automated deployment tasks
  7. How do you handle large data processing in commands?
    Use chunking with chunk() or cursor() methods, progress bars for user feedback, and queueing for background processing.
  8. Can Artisan commands be queued?
    Yes, by implementing the ShouldQueue interface and using the Dispatchable trait, commands can be processed in the background.

Performance Considerations

Memory Management

// Bad: Loads all records into memory
$users = User::all();

// Good: Processes in chunks
User::chunk(1000, function ($users) {
    foreach ($users as $user) {
        $this->processUser($user);
    }
});

// Better: Uses cursor for large datasets
foreach (User::cursor() as $user) {
    $this->processUser($user);
}

Timeout Handling

public function handle()
{
    set_time_limit(0); // Remove time limit for long-running commands
    
    // Or use Laravel's timeout handling
    $this->call('another:command', [
        '--timeout' => 300 // 5 minutes
    ]);
}

You've now mastered creating custom Laravel Artisan commands! From simple greeting commands to complex data processing tools, you have the knowledge to build powerful CLI interfaces for your Laravel applications.