Lesson 27 — Scheduler and Artisan Commands: TaskFlow needs a morning overdue-task summary and expired-token cleanup. We implement independently runnable commands before scheduling them; declarations alone do not start an external scheduler process.
1. Commands, scheduler and workers differ
A command performs work when invoked. The scheduler decides what is due. A queue worker consumes dispatched jobs. schedule:run does not consume lesson 26's maintenance queue, and queue:work does not trigger the 08:00 schedule.
ReportOverdueTasks counts unfinished tasks whose due_at is strictly before the current time, excluding equal and null deadlines. It uses lesson 15's Clock and logs only an aggregate count without changing task state. This is a system-operator command, not a user-facing endpoint.
2. Command, schedule and tests
routes/console.php
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('taskflow:report-overdue')
->dailyAt('08:00')->timezone('Asia/Ho_Chi_Minh')
->withoutOverlapping(10)->onOneServer();
Schedule::command('sanctum:prune-expired --hours=24')
->dailyAt('02:00')->timezone('Asia/Ho_Chi_Minh')
->withoutOverlapping(30)->onOneServer();tests/Feature/TaskScheduleTest.php
<?php
namespace Tests\Feature;
use App\Models\Task;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
class TaskScheduleTest extends TestCase
{
public function test_overdue_command_reports_only_unfinished_past_due_tasks(): void
{
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
$this->travelTo(\Carbon\CarbonImmutable::parse('2026-09-22 01:00:00', 'UTC'));
Task::factory()->create(['due_at' => now()->subMinute()]);
Task::factory()->done()->create(['due_at' => now()->subDay()]);
Task::factory()->create(['due_at' => now()]);
Task::factory()->create(['due_at' => null]);
Log::spy();
$this->artisan('taskflow:report-overdue')->expectsOutput('Overdue tasks: 1')->assertSuccessful();
Log::shouldHaveReceived('info')->once()->with('taskflow.overdue.summary', ['count' => 1]);
$this->assertDatabaseCount('tasks', 4);
$this->travelBack();
}
public function test_schedule_has_explicit_timezone_and_overlap_controls(): void
{
$events = collect(app(Schedule::class)->events());
foreach (['taskflow:report-overdue' => '0 8 * * *', 'sanctum:prune-expired' => '0 2 * * *'] as $name => $cron) {
$event = $events->first(fn ($event) => str_contains($event->command ?? '', $name));
$this->assertNotNull($event);
$this->assertSame($cron, $event->expression);
$this->assertSame('Asia/Ho_Chi_Minh', $event->timezone);
$this->assertTrue($event->withoutOverlapping);
$this->assertTrue($event->onOneServer);
}
}
}app/Console/Commands/ReportOverdueTasks.php
<?php
namespace App\Console\Commands;
use App\Contracts\Clock;
use App\Models\Task;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ReportOverdueTasks extends Command
{
protected $signature = 'taskflow:report-overdue';
protected $description = 'Report an aggregate overdue task count without changing task state';
public function handle(Clock $clock): int
{
$count = Task::query()->where('status', '!=', 'done')
->whereNotNull('due_at')->where('due_at', '<', $clock->now())->count();
$this->info('Overdue tasks: '.$count);
Log::info('taskflow.overdue.summary', ['count' => $count]);
return self::SUCCESS;
}
}The summary runs at 08:00 Asia/Ho_Chi_Minh and token cleanup at 02:00. withoutOverlapping uses a cache lock; its argument is lock expiry in minutes, not process timeout. onOneServer requires an appropriate shared cache backend. Separate machine-local stores do not become a distributed lock through this method alone.
sanctum:prune-expired --hours=24 deletes expired records after a retention interval; it does not grant an additional day of access. Authentication already rejects expired tokens. Running cleanup really deletes records, so verify database targets and retention policy before enabling it.
3. Development checks
php artisan taskflow:report-overdue
php artisan schedule:list
php artisan test --filter=TaskScheduleTest
php artisan schedule:work
Use a migrated development database. schedule:work occupies a terminal; closing it stops that runner. The summary may correctly return zero because the current UI does not accept deadlines, although models/factories support due_at. Test boundaries with fixtures rather than changing user data.
The course runtime's schedule:list displays UTC cron equivalents: 08:00 in Vietnam is 01:00 UTC; 02:00 in Vietnam is 19:00 UTC on the previous date. Interpret the timezone and Next Due together rather than diagnosing an error from the cron column alone.
4. Activate it on a server
Example Linux cron, replacing paths with the actual deployment and using the intended runtime identity:
* * * * * cd /srv/taskflow/current && /usr/bin/php artisan schedule:run >> /var/log/taskflow-scheduler.log 2>&1
This configuration has not been installed on a server by the lesson. Provide writable, rotated logs with controlled access. Windows Task Scheduler can invoke PHP/artisan every minute with the correct working directory; verify login conditions, sleep/power behavior and execution history. An editor extension is not proof of a running scheduler.
Choose one deliberate activation mechanism rather than enabling cron and several schedule:work processes unnecessarily. Missed schedule times are not automatically replayed as a complete backlog. Catch-up requirements need checkpoints, backfill and idempotent work.
5. Evidence and limitations
The suite passed 81 tests with 378 assertions. Command tests use in-memory SQLite and a fixed clock; schedule tests inspect expression, timezone and lock flags. schedule:list confirms registration. None of these proves unattended daily production execution or distributed-lock behavior.
Monitor last successful completion, exit status, runtime and overdue runs, not only process existence. Investigate before retrying side-effecting work. Never clear a lock without confirming its previous owner stopped. Retention and recovery remain operational responsibilities beyond a schedule declaration.
Exercises: test an empty overdue set, inspect schedule:list under another timezone and design an alert for a summary missing for more than a day. Navigation: Lesson 26 · Roadmap. Next: mail, notifications and uploads.




No comments yet. Be the first to share your thoughts.