Bài 27 — Scheduler và Artisan Command: TaskFlow cần thống kê task quá hạn mỗi sáng và dọn token đã hết hạn. Ta tách công việc thành command chạy độc lập, rồi mới khai báo thời điểm gọi; lịch không tự chạy nếu thiếu tiến trình kích hoạt bên ngoài.
1. Command, scheduler và worker khác nhau
Command thực hiện công việc khi được gọi. Scheduler xác định công việc nào đến giờ. Queue worker xử lý job đã enqueue. schedule:run không tiêu thụ queue maintenance của bài 26, và queue:work không tự kích hoạt lịch 08:00.
ReportOverdueTasks chỉ đếm task chưa done có due_at nhỏ hơn thời điểm hiện tại. Task đúng bằng hiện tại chưa quá hạn; due_at null không được đếm. Command dùng Clock từ bài 15, log số lượng tổng hợp, không log tiêu đề hoặc mô tả và không sửa trạng thái. Đây là command vận hành toàn hệ thống, không phải endpoint người dùng.
2. Code command, lịch và test
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;
}
}Thống kê chạy 08:00 Asia/Ho_Chi_Minh; dọn token chạy 02:00 cùng timezone. withoutOverlapping dùng cache lock để hạn chế lần chạy chồng; đối số là thời gian hết hạn khóa tính bằng phút, không phải timeout tiến trình. onOneServer yêu cầu backend cache chia sẻ phù hợp giữa các máy; cấu hình này không tự cung cấp distributed lock nếu mỗi máy dùng kho riêng.
sanctum:prune-expired --hours=24 xóa record token đã quá hạn đủ khoảng lưu giữ, không cấp thêm 24 giờ quyền truy cập. Token hết hạn đã bị xác thực từ chối trước lúc record bị dọn. Đây là thao tác xóa thật khi chạy; xác nhận database và chính sách giữ dữ liệu trước khi bật lịch.
3. Thử trên development
php artisan taskflow:report-overdue
php artisan schedule:list
php artisan test --filter=TaskScheduleTest
php artisan schedule:work
Chỉ chạy sau khi database phát triển đã migrate. schedule:work giữ tiến trình trong terminal; đóng terminal thì không còn runner đó. Command thống kê có thể trả zero vì UI hiện chưa cho nhập deadline, dù model/factory hỗ trợ due_at. Tạo fixture trong test để kiểm chứng ranh giới thay vì sửa dữ liệu người dùng.
schedule:list của runtime khóa học hiển thị cron theo timezone ứng dụng UTC: 08:00 Việt Nam tương ứng 01:00 UTC, 02:00 Việt Nam tương ứng 19:00 UTC ngày trước. Đọc timezone và Next Due cùng nhau, không kết luận lệch giờ chỉ từ cột cron.
4. Kích hoạt trên server
Ví dụ cron Linux gọi scheduler mỗi phút, thay đường dẫn bằng deployment thực tế và dùng đúng user runtime:
* * * * * cd /srv/taskflow/current && /usr/bin/php artisan schedule:run >> /var/log/taskflow-scheduler.log 2>&1
Đây là ví dụ cấu hình, chưa được cài lên máy chủ bởi bài học. Đảm bảo file log có quyền ghi, rotation và không lộ dữ liệu nhạy cảm. Windows có thể dùng Task Scheduler gọi PHP/artisan theo phút với working directory đúng; cần kiểm tra điều kiện đăng nhập, sleep, nguồn điện và lịch sử chạy. Không dùng extension editor làm bằng chứng scheduler đang sống.
Chỉ chọn một cơ chế kích hoạt có chủ đích; không bật cron và nhiều schedule:work rồi dựa hoàn toàn vào khóa để che cấu hình thừa. Server bỏ lỡ thời điểm chạy không mặc nhiên replay mọi lần đã lỡ. Công việc cần bù phải có checkpoint/backfill và idempotency riêng.
5. Bằng chứng và giới hạn
Suite đạt 81 test, 378 assertions. Test command dùng SQLite bộ nhớ và thời điểm cố định; test schedule kiểm tra expression, timezone, withoutOverlapping và onOneServer. schedule:list đã được chạy để xác nhận đăng ký. Những kiểm tra này không chứng minh cron production chạy hàng ngày hoặc lock đa máy hoạt động.
Theo dõi lần thành công gần nhất, exit code, thời gian chạy và cảnh báo quá hạn, không chỉ “process còn sống”. Khi command lỗi, điều tra trước khi chạy lại công việc có side effect. Không xóa cache lock thủ công nếu chưa xác nhận tiến trình cũ đã dừng. Backup/restore và retention token thuộc quy trình vận hành, không được thay bằng một dòng schedule.
Bài tập: thêm test không có task quá hạn, thử schedule:list với timezone khác và thiết kế cảnh báo khi thống kê không chạy quá một ngày. Điều hướng: Bài 26 · Lộ trình. Tiếp theo: mail, notification và upload file.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.