Laravel Artisan and Custom Commands

Writing a custom Artisan command and scheduling it to run automatically.

What Artisan is

Artisan is Laravel's command-line interface — php artisan migrate, php artisan make:model, and php artisan serve (all used earlier in this track) are Artisan commands. php artisan list shows every command available in a project, including ones added by installed packages.

Writing a custom command

Bash
php artisan make:command SendInactiveUserReminders
PHP
<?php
// app/Console/Commands/SendInactiveUserReminders.php

namespace App\Console\Commands;

use App\Mail\InactiveReminderEmail;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;

class SendInactiveUserReminders extends Command
{
    protected $signature = 'users:remind-inactive {--days=30 : Days of inactivity before a reminder}';

    protected $description = 'Email a reminder to users inactive for the given number of days';

    public function handle(): int
    {
        $days = (int) $this->option('days');

        $users = User::where('last_active_at', '<=', now()->subDays($days))->get();

        if ($users->isEmpty()) {
            $this->info('No inactive users found.');
            return self::SUCCESS;
        }

        $this->withProgressBar($users, function (User $user) {
            Mail::to($user->email)->send(new InactiveReminderEmail($user));
        });

        $this->newLine();
        $this->info("Sent {$users->count()} reminder(s).");

        return self::SUCCESS;
    }
}
Bash
php artisan users:remind-inactive --days=45

$signature defines both the command's name (users:remind-inactive) and its arguments/options in one compact string — {--days=30 : ...} declares an optional --days flag with a default of 30 and inline help text, which shows up automatically in php artisan users:remind-inactive --help. $this->info(), $this->withProgressBar(), and the self::SUCCESS/self::FAILURE return-code constants are all part of Artisan's built-in console I/O helpers — no separate CLI library needed for reasonable-looking terminal output.

Scheduling it

Running a command by hand works, but the real value of something like "remind inactive users" is running it automatically on a recurring basis. Laravel's scheduler defines that cadence in code — no separate crontab entries per task, no keeping cron in sync across servers.

PHP
<?php
// routes/console.php

use App\Console\Commands\SendInactiveUserReminders;
use Illuminate\Support\Facades\Schedule;

Schedule::command(SendInactiveUserReminders::class, ['--days=30'])
    ->daily()
    ->at('02:00')
    ->onOneServer();

->daily()->at('02:00') runs the command every day at 2 AM; ->weekly(), ->hourly(), and ->cron('*/15 * * * *') (a raw cron expression, for anything the fluent helpers don't cover) are also available. ->onOneServer() matters the moment the app is deployed across more than one server — without it, every server running the scheduler would independently kick off the same job at the same time, sending each user several duplicate reminder emails instead of one.

The scheduler itself needs exactly one real cron entry, added once to the server (not per-task), which triggers Laravel's own dispatch logic every minute:

Bash
* * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1

schedule:run checks every defined schedule entry and runs whichever ones are actually due at that minute — the one crontab line covers every scheduled task the whole application ever defines, present or future.

Common mistakes

  • Adding a new scheduled task in routes/console.php but never confirming the one required schedule:run cron entry actually exists on the server — nothing runs, silently, until someone notices the missing reminders/reports.
  • Scheduling a task without ->onOneServer() on a multi-server deployment — the same job fires redundantly on every server at once instead of exactly once.
  • Doing real business logic directly inside the console command's handle() method instead of delegating to a service class or a queued job — makes the same logic hard to reuse or test outside of the CLI context.

Interview questions

Q: What's the purpose of $signature on a custom Artisan command? It declares the command's name plus its arguments and options in one compact string ('users:remind-inactive {--days=30 : ...}'), which Laravel parses to both register the command under that name and to generate its --help output, argument validation, and default values automatically — no separate argument-parsing code needed.

Q: Why does Laravel's scheduler need only one crontab entry no matter how many scheduled tasks the app defines? The single entry runs php artisan schedule:run every minute, and that command itself checks every task registered in routes/console.php (via Schedule::command() and similar), running whichever ones are actually due at that moment. This means adding, removing, or changing a scheduled task's timing is a one-line code change — reviewed and deployed like any other code — instead of requiring a server-level crontab edit for every task.