blog-app 如果需要「每天凌晨自動清除超過半年的草稿」,這種定時任務就是今天的主題。Artisan 指令的寫法完全沒變,變的是排程定義的位置——這是繼 Day 16 Middleware 之後,第二個「整段程式碼要搬家」的章節。
php artisan make:command PrunePosts
// app/Console/Commands/PrunePosts.php
class PrunePosts extends Command
{
protected $signature = 'posts:prune {--months=6}';
protected $description = '清除超過指定月數的未發布草稿文章';
public function handle(): int
{
$months = (int) $this->option('months');
$count = Post::whereNull('published_at')
->where('created_at', '<', now()->subMonths($months))
->delete();
$this->info("已清除 {$count} 篇過期草稿。");
return self::SUCCESS;
}
}
protected $signature = 'posts:prune
{--months=6 : 保留幾個月內的草稿}
{user? : 只清除特定使用者的草稿(選填)}';
| 語法 | 意義 |
|---|---|
{user} |
必填參數 |
{user?} |
選填參數 |
{user=1} |
選填參數,帶預設值 |
{--months} |
選項(flag),沒帶值時是 boolean |
{--months=6} |
帶值的選項,可設預設值 |
{--tag=*} |
陣列選項,可重複帶多次(--tag=a --tag=b) |
handle() 的回傳值對應終端機執行後的結束代碼(exit code),這對 shell script 或 CI/CD 流程判斷指令是否成功很重要:
public function handle(): int
{
if (! $this->confirmOperation()) {
return self::INVALID; // 對應 exit code 2,代表輸入無效
}
if ($somethingWentWrong) {
return self::FAILURE; // 對應 exit code 1
}
return self::SUCCESS; // 對應 exit code 0
}
$? -eq 0(Linux/Mac)或 $LASTEXITCODE -eq 0(PowerShell)這類 shell 判斷,都是依賴這個結束代碼——如果 PrunePosts 被包進一個部署腳本,腳本可以依這個結束代碼決定要不要繼續執行後續步驟。
指令內也能用互動式輸入,適合需要使用者確認的操作:
public function handle(): int
{
if (! $this->confirm('確定要清除過期草稿嗎?')) {
return self::FAILURE;
}
$months = $this->anticipate('要保留幾個月內的草稿?', ['3', '6', '12']);
// ...
$this->table(['ID', 'Title'], $deletedPosts->map(fn ($p) => [$p->id, $p->title])->toArray());
return self::SUCCESS;
}
ask()/secret()/confirm()/anticipate()/choice() 這幾個互動方法,加上 table() 格式化輸出、createProgressBar() 進度條,讓一個 CLI 指令也能有不錯的操作體驗,blog-app 這類需要人工操作的維運指令很適合用上。
如果你想要更精緻的終端機互動介面(例如帶自動完成的選單、多選核取方塊),Laravel 官方另外維護了一個獨立套件 laravel/prompts(新版 Starter Kit 產生的指令範例通常已經預設引入):
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\select;
public function handle(): int
{
if (! confirm('確定要清除過期草稿嗎?')) {
return self::FAILURE;
}
$months = select(
label: '要保留幾個月內的草稿?',
options: ['3' => '3 個月', '6' => '6 個月', '12' => '12 個月'],
);
// ...
}
跟內建的 $this->confirm()/$this->choice() 相比,laravel/prompts 提供更漂亮的終端機視覺效果(顏色、游標互動),功能上是同一件事的加強版,blog-app 這系列的範例維持用內建方法示範,保持跟框架核心 API 一致,但如果你想要更好的 CLI 體驗,這個套件值得認識。
如果邏輯簡單到不需要獨立一個 Command 類別,可以直接在 routes/console.php 用 Closure 定義:
// routes/console.php
Artisan::command('posts:stats', function () {
$total = Post::count();
$published = Post::whereNotNull('published_at')->count();
$this->info("總文章數:{$total},已發布:{$published}");
})->purpose('顯示文章統計資訊');
routes/console.php 這個檔案的位置跟用途都沒變,變的只是誰載入它:新骨架由 bootstrap/app.php 的 withRouting() 直接指定 commands: __DIR__.'/../routes/console.php'(Day 5 就出現過這行),不再經過獨立的 Console Kernel 類別。
php artisan posts:prune --months=3
程式碼裡呼叫另一個指令:
Artisan::call('posts:prune', ['--months' => 3]);
$exitCode = Artisan::call('posts:prune');
// 在另一個 Command 內部呼叫
$this->call('posts:stats');
$this->callSilently('posts:stats'); // 不輸出該指令本身的輸出內容
Artisan::queue('posts:prune'); // 排進佇列非同步執行,而不是立即執行
今天的核心更新。舊寫法是在 app/Console/Kernel.php 的 schedule() 方法裡累加任務;那個檔案在 Laravel 11 起不存在了,改用 Schedule facade 直接寫在 routes/console.php:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('posts:prune --months=6')->daily();
Schedule::call(function () {
Log::info('每小時健康檢查', ['post_count' => Post::count()]);
})->hourly();
概念完全相同,只是不再需要一個獨立的 Kernel 類別來承接。
Schedule::command('posts:prune')->everyMinute();
Schedule::command('posts:prune')->hourly();
Schedule::command('posts:prune')->daily();
Schedule::command('posts:prune')->dailyAt('02:00');
Schedule::command('posts:prune')->weeklyOn(1, '02:00'); // 每週一凌晨兩點
Schedule::command('posts:prune')->monthly();
Schedule::command('posts:prune')->cron('0 2 * * *'); // 自訂 cron 表達式
// 條件限定
Schedule::command('posts:prune')->daily()->weekdays();
Schedule::command('posts:prune')->daily()->between('01:00', '05:00');
Schedule::command('posts:prune')->daily()->when(fn () => ! app()->isDownForMaintenance());
Schedule::command('posts:prune')->daily()->environments(['production']);
Schedule::command('posts:prune')
->daily()
->withoutOverlapping() // 上一次還在跑就跳過這一次,避免重疊執行
->onOneServer() // 多台伺服器只讓其中一台實際執行(避免重複跑)
->runInBackground() // 不阻塞排程器繼續派送下一個任務
->evenInMaintenanceMode(); // 就算網站在維護模式也照常執行
onOneServer() 在 blog-app 未來如果部署多台應用伺服器時特別重要——沒有這個設定,每台伺服器都會各自觸發同一個排程,同一個任務可能被跑好幾次。
跟 Day 19 Job 的 failed() 方法類似,排程任務也能掛接執行前後的邏輯:
Schedule::command('posts:prune')
->daily()
->before(function () {
Log::info('開始清除過期草稿');
})
->onSuccess(function () {
Log::info('清除過期草稿成功');
})
->onFailure(function () {
Log::error('清除過期草稿失敗,需要人工檢查');
})
->emailOutputOnFailure('admin@blog-app.test'); // 只在失敗時把指令輸出內容寄信通知
emailOutputOnFailure() 特別實用——正常執行不會產生任何噪音通知,只有真正失敗時才主動告知負責維運的人,這跟 Day 18 提過的 throttle() 是同一種「只在真正需要關注時才發出信號」的設計思路。
伺服器上只需要設定一條 cron,交給 Laravel 自己判斷這個時間點該執行哪些排程:
* * * * * cd /path-to-blog-app && php artisan schedule:run >> /dev/null 2>&1

本地開發不需要設定系統 cron,直接跑:
php artisan schedule:work
這個指令會在前景常駐執行,每分鐘自動檢查一次該不該觸發排程,方便本機測試排程邏輯是否符合預期。
如果你想讓 routes/console.php 專門放指令定義、排程另外集中管理,官方提供 bootstrap/app.php 的鏈式方法,閉包會收到一個 Schedule 實例:
// bootstrap/app.php
use Illuminate\Console\Scheduling\Schedule;
->withSchedule(function (Schedule $schedule) {
$schedule->command('posts:prune')->daily();
})
兩種寫法效果相同,挑一種團隊統一即可。
這裡有個文件本身不一致的小地雷:官方的 Task Scheduling 文件寫的是 withSchedule(),但 Laravel 13 升級指南提到這個功能時,標題和內文用的是 ApplicationBuilder::withScheduling()。實際寫的時候以你的 IDE 自動完成、或 Application::configure() 回傳物件上真正存在的方法為準——這種官方文件兩處措辭不一致的情況偶爾會發生,遇到時不要硬記,直接查程式碼最可靠。
無論名稱如何,Laravel 13 對它做了一個低影響的內部調整:這裡註冊的排程會延後到 Schedule 實際被 resolve 時才生效,而不是應用程式啟動當下就立即註冊。多數專案不會有感,但如果你的邏輯剛好依賴「啟動當下立即註冊」的舊行為(機率很低),升級後值得留意。
兩個實務上很好用的新增功能:
php artisan schedule:pause # 暫停所有排程,不需要改程式碼或重新部署
php artisan schedule:continue # 恢復
部署或緊急排查時,可以直接把排程整個停下來,比註解掉程式碼再重新部署乾淨得多。如果某個任務就算在暫停期間也必須照常執行,用 ->evenWhenPaused() 標記。
排程群組則是把共用設定抽出來,避免每條排程重複寫一樣的修飾方法:
Schedule::daily()
->onOneServer()
->timezone('Asia/Taipei')
->group(function () {
Schedule::command('posts:prune');
Schedule::command('posts:stats');
});
Day 27 會深入完整測試主題,這裡先提一個跟今天內容直接相關的技巧:
test('posts:prune 會清除過期草稿', function () {
Post::factory()->create(['published_at' => null, 'created_at' => now()->subMonths(7)]);
Post::factory()->create(['published_at' => now()]);
$this->artisan('posts:prune', ['--months' => 6])
->expectsOutput('已清除 1 篇過期草稿。')
->assertExitCode(0);
$this->assertDatabaseCount('posts', 1);
});
test('posts:prune 詢問使用者確認', function () {
$this->artisan('posts:prune')
->expectsConfirmation('確定要清除過期草稿嗎?', 'no')
->assertExitCode(1);
});
$this->artisan() 提供一整套斷言方法(expectsOutput()/expectsConfirmation()/assertExitCode()),能模擬使用者輸入、驗證指令輸出內容跟結束代碼,不需要真的在終端機手動操作互動流程。
schedule:run,由 Laravel 自己判斷該執行哪些任務」,不要在系統 crontab 裡逐條對應每個 Schedule::command()。onOneServer():會導致同一個排程任務被每台伺服器各自觸發一次,這在「清除資料」這類操作上通常還好(頂多重複執行但結果一致),但如果是「寄送通知信」這類有副作用的操作,會變成使用者收到好幾封重複信件。withoutOverlapping():如果排程任務本身執行時間可能超過下一次觸發的間隔(例如每分鐘觸發、但任務有時要跑 3 分鐘),沒有這個保護會導致同一個任務同時有多個實例在跑,互相搶資源甚至產生資料不一致。handle() 忘記回傳明確的結束代碼:如果方法沒有明確 return,PHP 預設回傳 null,這在多數情況下會被當成成功(0),可能掩蓋掉指令實際上執行失敗的事實,部署腳本依賴結束代碼判斷成敗時容易誤判。自訂 Artisan 指令的核心 API($signature、handle()、互動方法)跟 Schedule 的時間間隔/修飾方法都完全沒變,重大更新只有一個:排程定義的位置從已消失的 app/Console/Kernel.php 搬到 routes/console.php 搭配 Schedule facade。伺服器上只需要一條系統 cron 的心智模型也沒變。今天也補上了結束代碼的意義、排程任務的執行結果 Hook、以及 Laravel 13 新增的 schedule:pause/schedule:continue 與排程群組。
一年之計在於春,一日之計在於晨 — 《增廣賢文》
Day 26 進入 HTTP Client:怎麼用 Http facade 呼叫外部 API,也是 Day 29 LINE Bot/OpenAI 整合實戰的先修知識。