iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Modern Web

我推的Laravel S2!系列 第 26

我推的Laravel S2|Day 25:Schedule 排程與自訂 Artisan Command

  • 分享至 

  • xImage
  •  

一句話破題

blog-app 如果需要「每天凌晨自動清除超過半年的草稿」,這種定時任務就是今天的主題。Artisan 指令的寫法完全沒變,變的是排程定義的位置——這是繼 Day 16 Middleware 之後,第二個「整段程式碼要搬家」的章節。

建立自訂 Artisan 指令

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;
    }
}

$signature 語法

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 Prompts

如果你想要更精緻的終端機互動介面(例如帶自動完成的選單、多選核取方塊),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.phpwithRouting() 直接指定 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'); // 排進佇列非同步執行,而不是立即執行

Schedule:從 Kernel.php 搬到 routes/console.php

今天的核心更新。舊寫法是在 app/Console/Kernel.phpschedule() 方法裡累加任務;那個檔案在 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 未來如果部署多台應用伺服器時特別重要——沒有這個設定,每台伺服器都會各自觸發同一個排程,同一個任務可能被跑好幾次。

排程任務的執行結果 Hook

跟 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 呼叫 schedule:run,由它負責逐一檢查每個已定義排程是否到期,到期才真正執行對應指令

本地開發不需要設定系統 cron,直接跑:

php artisan schedule:work

這個指令會在前景常駐執行,每分鐘自動檢查一次該不該觸發排程,方便本機測試排程邏輯是否符合預期。

另一個選項:bootstrap/app.php 的 withSchedule()

如果你想讓 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 時才生效,而不是應用程式啟動當下就立即註冊。多數專案不會有感,但如果你的邏輯剛好依賴「啟動當下立即註冊」的舊行為(機率很低),升級後值得留意。

Laravel 13 新增:暫停排程與排程群組

兩個實務上很好用的新增功能:

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()),能模擬使用者輸入、驗證指令輸出內容跟結束代碼,不需要真的在終端機手動操作互動流程。

常見錯誤與踩雷點

  • 忘記系統只需要一條 cron,卻對每個排程都各自設一條:Laravel 的排程系統設計是「一條 cron 每分鐘觸發一次 schedule:run,由 Laravel 自己判斷該執行哪些任務」,不要在系統 crontab 裡逐條對應每個 Schedule::command()
  • 多台伺服器忘記加 onOneServer():會導致同一個排程任務被每台伺服器各自觸發一次,這在「清除資料」這類操作上通常還好(頂多重複執行但結果一致),但如果是「寄送通知信」這類有副作用的操作,會變成使用者收到好幾封重複信件。
  • 長時間執行的任務忘記 withoutOverlapping():如果排程任務本身執行時間可能超過下一次觸發的間隔(例如每分鐘觸發、但任務有時要跑 3 分鐘),沒有這個保護會導致同一個任務同時有多個實例在跑,互相搶資源甚至產生資料不一致。
  • handle() 忘記回傳明確的結束代碼:如果方法沒有明確 return,PHP 預設回傳 null,這在多數情況下會被當成成功(0),可能掩蓋掉指令實際上執行失敗的事實,部署腳本依賴結束代碼判斷成敗時容易誤判。

小結

自訂 Artisan 指令的核心 API($signaturehandle()、互動方法)跟 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 整合實戰的先修知識。


上一篇
我推的Laravel S2|Day 24:Session 與 Cookie
下一篇
我推的Laravel S2|Day 26:HTTP Client:呼叫外部 API
系列文
我推的Laravel S2!27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言