Custom Commands & Scheduling
Writing Artisan commands, arguments and options, and the task scheduler.
Not every piece of work starts with a web request. Nightly reports, cleanup jobs, data imports and reminder emails all run from the command line or on a timer. Laravel gives you both: Artisan commands for the code, and the scheduler for when it runs.
This lesson builds one real command for the Projects & Tasks app, tasks:due-soon, which lists overdue and soon-due tasks, and then schedules it.
Try it: run a real Artisan command
This box runs php artisan tasks:due-soon on the server through GET /api/demo/commands/tasks-due-soon and shows exactly what the terminal prints. Change the days, or pick a project:
Generating a command
php artisan make:command TasksDueSoon
That creates app/Console/Commands/TasksDueSoon.php. Laravel discovers every class in app/Console/Commands automatically, so there's nothing to register. This is the finished command:
app/Console/Commands/TasksDueSoon.php
#[Signature('tasks:due-soon {--days=7 : Include tasks due within this many days} {--project= : Only this project slug}')]
#[Description('List open tasks that are overdue or due soon')]
class TasksDueSoon extends Command
{
/**
* Execute the console command.
*/
public function handle(FindDueTasks $findDueTasks): int
{
$days = (int) $this->option('days');
if ($days < 0 || $days > 365) {
$this->error('--days must be between 0 and 365.');
return self::INVALID;
}
$tasks = $findDueTasks->handle($days, $this->option('project'));
if ($tasks->isEmpty()) {
$this->info("Nothing due in the next {$days} days.");
return self::SUCCESS;
}
$this->table(
['ID', 'Task', 'Project', 'Assignee', 'Due', 'Status'],
$tasks->map(fn (DueTaskData $task): array => [
$task->id,
$task->title,
$task->project,
$task->assignee ?? '—',
$task->dueOn->toDateString(),
$task->isOverdue() ? abs($task->daysLeft).'d overdue' : "in {$task->daysLeft}d",
]),
);
$overdue = $tasks->filter->isOverdue()->count();
$this->line("{$tasks->count()} task(s), {$overdue} overdue.");
return self::SUCCESS;
}
}
Notice:
- Attributes for metadata. Laravel 13 commands declare their name and help text with the
#[Signature]and#[Description]attributes. Older code usesprotected $signatureandprotected $descriptionproperties, which still work. handle()is resolved by the container. Type-hint any class, here theFindDueTasksaction, and Laravel injects it, just as it does for a controller method.- The command has no query logic of its own. It parses input, calls an action, and formats output. The same action powers an HTTP endpoint (see Services, Actions & Architecture).
- Return an exit code.
self::SUCCESS(0),self::FAILURE(1) orself::INVALID(2). Cron, CI and shell scripts read that number to decide whether something went wrong.
The signature syntax
| Signature part | Meaning | Read it with |
|---|---|---|
{user} |
Required argument | $this->argument('user') |
{user?} |
Optional argument | |
{user=admin} |
Argument with a default | |
{users*} |
Array of arguments | returns an array |
{--force} |
Boolean flag | $this->option('force') → true/false |
{--days=7} |
Option with a value and default | $this->option('days') |
{--D|days=7} |
Option with a shortcut (-D 3) |
|
{--days= : Help text} |
Text after a colon shows in --help |
Run php artisan tasks:due-soon --help and Laravel builds the help screen from the signature.
Talking to the terminal
The output and input helpers you'll use most:
$this->info('Done.'); // green
$this->error('Something failed.'); // red
$this->warn('Careful…'); // yellow
$this->line('Plain text');
$this->table(['ID', 'Name'], $rows); // the table in the demo above
$this->newLine();
$name = $this->ask('Project name?');
$secret = $this->secret('API key?'); // input is hidden
if ($this->confirm('Delete 40 tasks?')) { /* ... */ } // yes / no
$level = $this->choice('Priority', ['low', 'medium', 'high'], 'medium');
$this->withProgressBar(Task::cursor(), fn (Task $task) => $this->process($task));
For long jobs, iterate with Task::cursor() or ->lazy() rather than ->get(). Both stream rows from the database instead of loading every model into memory at once.
Quick commands as closures
Small one-off commands don't need a class. Define them in routes/console.php, as Laravel does for the built-in inspire command:
routes/console.php
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Scheduling: one cron entry for everything
The old way was one crontab line per job, edited on each server. With Laravel's scheduler, the server has a single cron entry that runs every minute:
* * * * * cd /var/www/demo1 && php artisan schedule:run >> /dev/null 2>&1
schedule:run then checks, in PHP, which jobs are due right now. Schedules live in version control next to the code, and you can see them all with php artisan schedule:list.
Schedules go in routes/console.php. This is the real one for our command:
routes/console.php
// Morning digest of overdue / due-soon tasks (needs the scheduler cron entry)
Schedule::command('tasks:due-soon --days=3')
->weekdays()
->dailyAt('08:00')
->withoutOverlapping()
->appendOutputTo(storage_path('logs/tasks-due-soon.log'));
Read it as a sentence: on weekdays at 08:00, run the command, never two copies at once, and append its output to a log file.
Frequency and constraint cheat sheet
| Method | Runs |
|---|---|
->everyMinute(), ->everyFiveMinutes(), ->everyFifteenMinutes() |
On that interval |
->hourly(), ->hourlyAt(17) |
Every hour (at :17) |
->daily(), ->dailyAt('13:00'), ->twiceDaily(1, 13) |
Once or twice a day |
->weekly(), ->weeklyOn(1, '8:00'), ->monthly() |
Weekly or monthly |
->cron('0 8 * * 1-5') |
Any raw cron expression |
->weekdays(), ->weekends(), ->between('9:00', '17:00') |
Constraints you can chain |
->timezone('Europe/London') |
Interpret times in that zone |
->when(fn () => ...), ->environments('production') |
Only run when a condition holds |
The useful options for production:
| Option | Why |
|---|---|
->withoutOverlapping() |
Skip a run if the previous one is still going |
->onOneServer() |
With several servers, only one runs it (needs a shared cache such as Redis) |
->runInBackground() |
Don't block the other jobs due in the same minute |
->appendOutputTo($path) / ->emailOutputOnFailure($email) |
Keep a record, and get told when it breaks |
->onSuccess() / ->onFailure() |
Run callbacks, for example to ping a monitoring URL |
You can schedule more than commands: Schedule::job(new PruneOldExports)->daily() queues a job, and Schedule::call(fn () => ...) runs a closure.
Testing commands and schedules
$this->artisan() runs a command inside a test and lets you assert on its output and exit code. Asserting on the schedule itself catches someone accidentally changing "weekdays at 8" to "every minute":
tests/Feature/Demo/TasksDueSoonCommandTest.php (excerpt)
public function test_it_lists_overdue_and_due_soon_tasks(): void
{
$this->travelTo(now()->startOfDay());
Task::factory()->create(['title' => 'Late task', 'due_on' => now()->subDay()]);
Task::factory()->create(['title' => 'Far future', 'due_on' => now()->addDays(60)]);
$this->artisan('tasks:due-soon', ['--days' => 7])
->expectsOutputToContain('Late task')
->doesntExpectOutputToContain('Far future')
->expectsOutputToContain('1 task(s), 1 overdue.')
->assertSuccessful();
}
public function test_it_rejects_invalid_days(): void
{
$this->artisan('tasks:due-soon', ['--days' => -1])->assertExitCode(2);
}
public function test_it_is_scheduled_on_weekday_mornings(): void
{
$event = collect(app(Schedule::class)->events())
->first(fn ($event) => str_contains($event->command, 'tasks:due-soon'));
$this->assertNotNull($event);
$this->assertSame('0 8 * * 1-5', $event->expression);
}
travelTo() freezes "now", so "due yesterday" means the same thing every time the test runs. Interactive commands are tested with ->expectsQuestion('Project name?', 'Apollo') and ->expectsConfirmation('Delete 40 tasks?', 'yes').
Calling commands from code
Artisan::call() runs a command from anywhere and Artisan::output() returns what it printed. That's how the demo endpoint at the top of this page works:
app/Http/Controllers/Api/DueSoonCommandController.php (excerpt)
$exitCode = Artisan::call('tasks:due-soon', $arguments);
return response()->json([
'command' => 'php artisan tasks:due-soon '.collect($arguments)->map(fn ($value, $option) => "{$option}={$value}")->implode(' '),
'exit_code' => $exitCode,
'output' => Artisan::output(),
]);
Only expose read-only commands this way, and validate every argument. This endpoint accepts a number of days and an existing project slug, nothing else. For slow commands, use Artisan::queue() so the request doesn't wait.
Key takeaways
make:commandcreates a class inapp/Console/Commands. It's auto-registered, with its name and help text declared by#[Signature]and#[Description].- The signature defines arguments and options. Keep
handle()thin by delegating to an action or service, and return an exit code. - One server cron entry runs
schedule:runevery minute. Every schedule lives inroutes/console.php, in version control. - In production, add
withoutOverlapping(),onOneServer()and output logging or alerts. - Test commands with
$this->artisan()->expectsOutputToContain()->assertSuccessful(), and assert the schedule's cron expression.