Custom Artisan Commands

Wrap recurring operational tasks in a first-class CLI command — schedulable, testable, discoverable.

Cron one-liners and copy-pasted tinker snippets are where operational knowledge goes to die. A custom Artisan command turns "that thing Dave runs on the server" into a documented, testable, schedulable part of the codebase.

Anatomy

Generate with php artisan make:command. The $signature declares the name plus arguments and options (with defaults and descriptions); handle() is the body and supports full dependency injection.

class PruneGuests extends Command
{
    protected $signature = 'users:prune-guests {--days=30}';
    protected $description = 'Delete guest accounts older than N days';

    public function handle(): int
    {
        $n = User::guests()->olderThan($this->option('days'))->delete();
        $this->info("Pruned {$n} guests.");
        return self::SUCCESS;
    }
}

A humane CLI

Use $this->info/warn/error for output, table() for tabular data, withProgressBar() for long loops, and the interactive confirm()/select() prompts. Return SUCCESS/FAILURE so CI and cron know what happened.

Scheduling

In Laravel 11+/12 you schedule commands in routes/console.php with Schedule::command('users:prune-guests')->daily().

Example

Example · php
<?php

namespace App\Console\Commands;

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

class SendOverdueReminders extends Command
{
    protected $signature = 'invoices:remind
                            {--days=7 : Days overdue before reminding}
                            {--dry-run : Report without sending}';

    protected $description = 'Email reminders for overdue invoices';

    public function handle(): int
    {
        $cutoff = now()->subDays((int) $this->option('days'));
        $query  = Invoice::overdueSince($cutoff);

        if ($query->doesntExist()) {
            $this->info('Nothing overdue. Sleep well.');
            return self::SUCCESS;
        }

        $dryRun = $this->option('dry-run');
        $sent   = 0;

        $this->withProgressBar($query->cursor(), function (Invoice $invoice) use ($dryRun, &$sent) {
            if (! $dryRun) {
                \App\Jobs\SendReminder::dispatch($invoice);
            }
            $sent++;
        });

        $this->newLine();
        $this->info(($dryRun ? '[dry-run] Would send ' : 'Queued ') . "{$sent} reminders.");
        return self::SUCCESS;
    }
}

// Scheduled in routes/console.php:
// Schedule::command('invoices:remind')->weekdays()->at('09:00')
//         ->withoutOverlapping()->onOneServer();

When to use it

  • A team creates a custom app:sync-products command that pulls data from an external supplier API and upserts products into the database, schedulable via the kernel.
  • A deployment script calls php artisan app:warm-cache after each release to pre-populate expensive cached queries so the first real users see fast responses.
  • A developer adds a --notify option to a custom command so operators can trigger email alerts when the command completes, without modifying the core command logic.

More examples

Full custom command class

Demonstrates a command with typed options, a service injected via handle(), table output, and the conventional SUCCESS return code.

Example · php
class SyncProducts extends Command
{
    protected $signature = 'app:sync-products
                            {--source=api : Data source (api|csv)}
                            {--dry-run : Preview without writing}';
    protected $description = 'Sync products from the external supplier feed';

    public function handle(ProductSyncService $service): int
    {
        $source = $this->option('source');
        $isDry  = $this->option('dry-run');

        $this->info("Syncing from [{$source}]...");
        $count = $service->sync($source, dryRun: $isDry);
        $this->table(['Metric', 'Value'], [['Products synced', $count]]);

        return self::SUCCESS;
    }
}

Input types: arguments, options, and confirmation

Shows positional arguments, value options with defaults resolved in handle(), and confirm() to prompt the operator before a destructive action.

Example · php
protected $signature = 'report:generate
    {type : Report type (sales|inventory)}
    {--from= : Start date}
    {--to= : End date}
    {--mail= : Email recipient}';

public function handle(): int
{
    $type = $this->argument('type');
    $from = $this->option('from') ?? now()->startOfMonth()->toDateString();

    if (! $this->confirm("Generate {$type} report from {$from}?")) {
        $this->line('Cancelled.');
        return self::SUCCESS;
    }
    // ...
}

Schedule the command in the kernel

withoutOverlapping() prevents concurrent runs; sendOutputTo() captures output; emailOutputOnFailure() alerts the team if the command exits non-zero.

Example · php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    $schedule->command('app:sync-products --source=api')
        ->hourly()
        ->withoutOverlapping()
        ->sendOutputTo(storage_path('logs/sync.log'));

    $schedule->command('report:generate sales')
        ->weeklyOn(1, '06:00')  // Monday at 6 AM
        ->emailOutputOnFailure('[email protected]');
}

Discussion

  • Be the first to comment on this lesson.