Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

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 uses protected $signature and protected $description properties, which still work.
  • handle() is resolved by the container. Type-hint any class, here the FindDueTasks action, 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) or self::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.

Server cron * * * * * (every min) schedule:run which events are due now? tasks:due-soon Mon–Fri 08:00 → runs other events not due → skipped

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:command creates a class in app/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:run every minute. Every schedule lives in routes/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.