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
<?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.
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.
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.
// 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