Search by

matrix-platform / laravel-base

jrealm

Package info

github.com/matrix-platform/laravel-base

pkg:composer/matrix-platform/laravel-base

Statistics

Installs: 82

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-10-14 12:31 UTC

This package is auto-updated.

Last update: 2026-10-14 23:19:48 UTC


README

Laravel 後台引擎。用一行字串 DSL 描述欄位,換到一整套後台 CRUD:清單、篩選、排序、分頁、表單驗證、複製、拖曳排序、匯出、匯入、稽核軌跡、以及以選單樹為基礎的授權模型。

class WidgetController extends CrudController {

    protected string $model = Widget::class;

    protected array $lists = ['id', 'title', 'status:select', 'category.title=category_title', 'create_time'];

    protected array $forms = ['title', 'status:select', 'category_id:select', 'content:text'];

}

上面這個類別會生出十個端點、一份帶型別與驗證規則的欄位描述、以及一組可以逐項授權的權限點。

目錄

套件不提供什麼

先講界線,因為這些是刻意的決定,不是還沒做:

沒有 說明
前台登入端點 套件出貨 member-api / vendor-api middleware 與 AuthToken::issue(),但沒有任何前台登入 controller。前台的登入流程、驗證碼策略、密碼規則由宿主決定
API 文件產生器 不出貨 Swagger / OpenAPI。端點是 #[Action] 反射掛載的,要文件請從 attribute 反射產生,不要掃註解
排程註冊 套件不呼叫 Schedule::command()。matrix:prune-tokens 與 messages:dispatch 都由宿主自己排(見註冊排程)
cache / queue driver 的選擇 套件用 Cache 與 Queue 門面,不指定 driver。驗證碼要跨請求共用的 cache,訊息派送要每條 queue 恰好一個 worker(見派送與 worker)
以匯入更新既有資料 匯入只做新增,每一列建一筆新資料;不以 id 或唯一鍵比對後更新(見匯入)
檔案清理 base_file 與磁碟上的檔案永遠不會被自動刪除,去重讓一筆記錄可能被多處引用,套件答不出「誰可以刪」
多資料庫支援 只支援 PostgreSQL,而且是硬性的(見下一節)

安裝

照順序走,每一步都有驗證動作。 中間任何一步失敗,不要往下走 —— 這套件的失敗多半是靜默的。

1. 環境

需求 為什麼 不滿足會怎樣
PostgreSQL 主鍵與排序值來自兩個共用 sequence(CREATE SEQUENCE / NEXTVAL)、權限與稽核用 jsonb、base_operator 是 CREATE OR REPLACE VIEW php artisan migrate 第一個 migration 的第一行就 SQL 語法錯誤
PHP 8.3+ —— composer 會擋
Laravel 13.23+ composer.json 宣告 ^13.23,那也是 --prefer-lowest 實際跑過完整測試的版本;低於它的 13.x 沒有驗證過 composer 會擋
ext-pdo_pgsql 上面那條的驅動,由 composer.json 強制檢查 composer 會擋
ext-gd,且編譯時帶 FreeType(只有把 matrix.admin-captcha-provider 設成 captcha-image 才要,預設不要) 圖形驗證碼用 imagettftext() 畫字 admin/auth/captcha 回 500,而驗證碼開著的時候登入一定要 code —— 完全登不進去。裝不了 GD 就改用 captcha-turnstile / captcha-recaptcha,或維持預設的 captcha-none
一個跨請求共用的 cache store(同上,只有 captcha-image 才要) 驗證碼答案寫在 cache,下一個請求才比對 CACHE_STORE=array 的話每次登入都是 invalid-captcha。多台機器沒有共用 cache 會隨機失敗

驗證:

php -r 'exit(function_exists("imagettftext") ? 0 : 1);' || echo 'GD 缺少 FreeType,後台將完全登不進去'

composer.json 只能要求 ext-gd 存在,擋不掉「有 GD 但沒有 FreeType」。這一條與 cache store 那一條都要到第一次有人登入才會爆,所以請把上面那行放進部署腳本或 CI —— 它回非零 exit code。

2. 安裝套件

composer require matrix-platform/laravel-base

服務提供者會自動註冊(extra.laravel.providers)。migration 與路由自動載入,不需要也不能發佈。

3. 建立 config/matrix.php

這個檔案一定要自己建。 套件的預設值走 mergeConfigFrom,只合併頂層 key —— 你宣告 messaging 就會整個取代掉套件的 messaging(包含它底下的 channels)。所以要嘛整份照抄,要嘛只宣告你真的要改的頂層 key,並把該 key 的完整內容一起寫上。

完整範本(等同套件出貨值):

<?php

use MatrixPlatform\Models\MailLog;
use MatrixPlatform\Models\Member;
use MatrixPlatform\Models\SmsLog;
use MatrixPlatform\Models\Vendor;

return [

    'admin-api-prefix' => 'admin',

    'admin-menus' => 'base',

    'api-prefix' => 'api',

    'file-private-disk' => 'local',

    'file-public-disk' => 'public',

    'locales' => 'tw en',

    'member-model' => Member::class,

    'messaging' => [
        'channels' => [
            'mail' => ['model' => MailLog::class, 'queue' => 'messaging-mail'],
            'sms' => ['model' => SmsLog::class, 'queue' => 'messaging-sms'],
        ],
    ],

    'packages' => 'app base',

    'resource-cfg' => [],

    'resource-i18n' => [],

    'resource-i18n-menu' => [],

    'resource-i18n-model' => [],

    'resource-i18n-options' => [],

    'resource-i18n-template' => [],

    'vendor-model' => Vendor::class,

];

語系要對得起來。 locales 列出的每一個值,都必須在 resources/i18n/{語系}/ 底下有對應目錄。Laravel 預設的 APP_LOCALE=en 可以直接用;若你設成 zh_TW 之類的值,套件找不到 resources/i18n/zh_TW/,整個後台的標題、選單、錯誤訊息會退化成原始 token(畫面看起來還在跑,只是字都變成 errors.permission-denied 這種東西)。

4. 跑 migration

php artisan migrate

套件的 migration 檔名是 0001_ 到 0007_,字串排序落在 Laravel 內建的 0001_01_01_* 之後、你自己的日期式 migration 之前。你自己的表如果要用套件的主鍵慣例,順序不能反過來 —— 0001_foundation 建立 base_id 與 base_ranking 兩個 sequence,你的 migration 必須排在它後面。

驗證: base_user、base_group、base_auth_token 等 17 張表存在。

5. 跑 seeder

套件出貨兩個 seeder,都不會自動執行,要在你自己的 DatabaseSeeder 裡呼叫:

public function run(): void {
    $this->call(\MatrixPlatform\Database\Seeders\UserSeeder::class);
    $this->call(\MatrixPlatform\Database\Seeders\CitySeeder::class);   // 台灣縣市與行政區,不需要就別跑
}

UserSeeder 建兩個帳號:root@matrix(id = 1)與 admin(id = 2)。兩個都沒有密碼,而且 id 是寫死的 —— 那是權限模型的一部分(見五個必讀概念)。

CitySeeder 寫入 394 列,而且每一列都會產生一筆稽核紀錄 —— 這是刻意的,稽核軌跡要答得出「這批資料是誰在什麼時候放進來的」。

驗證: base_user 有 id 1 與 2 兩列。

6. 設定管理員密碼

php artisan matrix:passwd root@matrix

指令會問兩次密碼。密碼規則來自 cfg('admin.password-pattern'),出貨值是「至少 8 碼、含英文與數字」。同一規則也套用在登入後自行改密碼與後台使用者表單的非空密碼;重設成功會撤銷該帳號所有既有 session。

這是建立管理員的唯一官方入口。 後台介面建出來的帳號一律是一般使用者 —— 因為 id 來自從 10000000 起跳的共用 sequence,而管理員等級是由 id 區間決定的。

驗證: 下一步能登入。

7. 打第一個請求

先產生加密金鑰圈:

php artisan matrix:rotate-encryption-key

matrix.admin-api-encryption 與 matrix.vendor-api-encryption 出貨是開的,金鑰圈空著的時候 encryption-key 只會回 encryption-unavailable,整個後台開不起來。

然後下面這兩個 curl 還是會失敗——加密開著的時候明文請求一律回 invalid-envelope,auth/captcha 與 auth/login 沒有宣告 #[Action(encrypted: false)]。要用 curl 驗證,先在你的 config/matrix.php 把 'admin-api-encryption' => false 關掉;要驗證加密路徑,用瀏覽器端(前端 initConfig 的 encryption 也是預設開的),或照給前端那節的線路格式自己組信封。

# 1. 取驗證碼(匿名)
curl -X POST http://localhost/admin/auth/captcha
# → {"success":true,"data":{"token":"...","image":"data:image/png;base64,..."}}

# 2. 登入(把圖片裡的字讀出來當 code)
curl -X POST http://localhost/admin/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"root@matrix","password":"...","token":"上一步的 token","code":"圖片裡的字"}'
# → {"success":true,"data":{"token":"..."}}

驗證: 拿到 token。用它打 POST admin/auth/profile 應該回傳選單樹與個人資料。

8. 註冊排程(如果要用訊息或 token 清理)

套件不註冊排程。在你的 routes/console.php:

Schedule::command('matrix:prune-tokens')->daily()->withoutOverlapping();
Schedule::command('messages:dispatch')->everyMinute()->withoutOverlapping();

withoutOverlapping() 不是裝飾:兩個 prune 行程同時跑會互相搶同一批列。

訊息還需要 queue worker。mail、sms、push、telegram 各有自己的 queue,而每條 queue 只能有一個 worker:

php artisan queue:work --queue=messaging-mail
php artisan queue:work --queue=messaging-sms
php artisan queue:work --queue=messaging-push
php artisan queue:work --queue=messaging-telegram

worker 會睡掉供應商的 interval 來節流,所以 --timeout 與 connection 的 retry_after 都必須大於最大的 interval。這三個數字沒對齊、或漏開一條 worker 的後果與訊號,見派送與 worker。

五個必讀概念

1. 回應永遠是 HTTP 200,狀態碼在 body 裡

{ "success": true,  "data": { } }
{ "success": false, "code": 404, "error": "data-not-found", "message": "查無資料" }
{ "success": false, "code": 422, "error": "validation-failed", "message": "...", "fields": { "username": ["required"] } }

這不是風格偏好:客戶端的防火牆會擋掉非 200 的回應。error 欄位就是 i18n key,message 是後端已經翻好的字串 —— 前端可以直接顯示 message,也可以拿 error 自己對照。唯一的例外是 GET api/files/{path}——它要直接當 <img src> 或瀏覽器重定向目標用,回真正的 302/404,不走信封(見「端點」表)。

信封的範圍是套件的兩個路由前綴。 admin/ 與 api/ 底下打不到的路徑、用錯方法的請求,都會回信封格式的 404,GET api/files/{path} 除外——它是刻意不信封化的公開檔案讀取端點。這兩個前綴之外的請求不受影響 —— 你自己的網頁 404 還是 Laravel 原本的樣子。

代價要知道:因為兜底路由吃下所有方法,「方法用錯」不再回 405,而是回 404 信封。前端分不出「網址拼錯」與「方法用錯」。

2. 全部端點都是 POST

除了 GET api/files/{path}(公開檔案讀取,詳見「端點」表)之外,其餘沒有一個 GET。讀取(清單、詳情)也是 POST。用 GET 打其他端點會拿到 404 信封。

3. 身分、middleware,以及順序

別名 作用
encrypted-api 解開/封回加密信封,只作用在 admin 前綴。必須掛在 envelope-api 之前,否則錯誤信封不會被加密
envelope-api 把例外收斂成信封。必須掛在 encrypted-api 以外的最外層
locale-api 讀 Matrix-Locale header 決定語系
user-api 後台身分,解析 token,失敗回 401 invalid-token
permission-api 授權,必須掛在 user-api 之後
member-api / vendor-api 前台身分
member-aware-api 有 token 就解析,沒有也放行
login-throttle-api:{bundle} 登入節流,鍵是 IP + 帳號

順序是約束,不是機制保證。 把 permission-api 掛在 user-api 前面不會有人阻止你,但授權會先跑,拿到的是 401 而不是 403。

token 兩種帶法:Authorization: Bearer {token},或登入時自動下的 matrix-user cookie。cookie 是 httpOnly,secure 跟隨 config('session.secure') —— 本機開發走 HTTP 時那個值必須是 false,否則瀏覽器不會回送,症狀是「登入成功但下一個請求就 401」。跨網域的前端要送 credentials: 'include',而且 CORS 要自己設(套件的前綴不在 Laravel 預設的 cors.paths 裡)。

4. 選單即權限

選單樹不只是導覽,它就是權限模型本身。

// resources/menu/{bundle}.php —— 縮排跟著選單層次,不是格式錯誤
'widget' => ['icon' => 'fa-solid fa-cube', 'ranking' => 100, 'parent' => 'catalog', 'group' => true, 'tag' => 'query'],

    'widget/{id}' => ['parent' => 'widget', 'tag' => 'query'],
    'widget/{id}/update' => ['parent' => 'widget', 'tag' => 'update'],
    'widget/delete' => ['parent' => 'widget', 'tag' => 'delete'],
    'widget/insert' => ['parent' => 'widget', 'tag' => 'insert'],
    'widget/new' => ['parent' => 'widget', 'tag' => 'insert'],

三條規則:

  1. key 就是路由 URI(去掉 admin/ 前綴),逐字相同,含 {id} 佔位符。
  2. group => true 的節點是授權單位,它底下子節點的 tag 匯總成可以授予的動作。
  3. 每一個端點都必須有對應的節點。漏一個,那個端點對所有人打不到 —— 包含 ROOT。 掛在 permission-api 群組裡的 ActionRoutes::mount() / scan() 只註冊選單有節點的 action,沒節點的路由根本不存在,回 404 endpoint-not-found;自己用 Route::post() 掛在群組裡的路由照常註冊,沒節點則由 permission-api 擋成 403。選單一律只來自檔案(不讀 base_resource_override,讀選單不碰資料庫),路由在開機時就依當下的 admin-menus 定案。

權限值存在 base_user.permissions 與 base_group.permissions 兩個 jsonb 欄位,形狀是 {"widget": {"query": true}}。

等級寫在主鍵裡:

id 等級 能做什麼
1 Root 全部,包含標記 system 的功能
2 – 1000 Admin 除了 system 之外全部,不看 permissions
10000000 起 Regular 只看 permissions

共用 sequence 從 10000000 起跳,所以後台介面建出來的帳號一律是 Regular —— 等級不可能被提權改掉,因為它不是一個欄位。管理員只能用 seeder 或手動指定 id 建立。

帳號管理也遵守同一個階層:Root 可以管理所有帳號;Admin 可以管理 Admin 與 Regular,但看不到 Root;Regular 即使取得 user 權限,也只能管理其他 Regular。超出可管理範圍的帳號在清單中不會出現,讀取、更新與刪除則回 data-not-found;任何帳號都不能刪除自己。

5. 資源疊層:你的檔案覆蓋套件的

設定、翻譯、選單都走同一套疊層。config('matrix.packages') 出貨 'app base',app 就是你的 Laravel 專案,排前面的覆蓋排後面的。

放什麼 路徑(相對於專案根目錄)
設定 resources/cfg/{bundle}.php
設定的欄位型別(給資源後台用) resources/style/cfg/{bundle}.php
選單 resources/menu/{bundle}.php
選單的標題 resources/i18n/{語系}/menu/{bundle}.php
資料表欄位的標題 resources/i18n/{語系}/model/{資料表名}.php
同一張表的某個 model 自己的欄位標題(逐 key 覆蓋上一列) resources/i18n/{語系}/model/{資料表名}-{Metadata alias}.php
下拉選項 resources/i18n/{語系}/options/{name}.php
錯誤訊息 resources/i18n/{語系}/errors.php
訊息樣板 resources/i18n/{語系}/template/{name}.php

bundle 一律扁平:只有一層 key,key 本身可以含點,取值時當字面看待,不做巢狀路徑解析。

resources/style/cfg/{bundle}.php 只是欄位型別表,不是欄位白名單——沒被它列到的 key 照樣會出現在資源後台,型別退回 text、呈現退回 plain。唯一的例外是標記了 'secret' => true 的 key:

'password' => ['type' => 'text', 'presentation' => 'password', 'secret' => true],

其他資源群組同理:型別表放 resources/style/{群組}/{name}.php(例如訊息樣板 resources/style/i18n/template/{name}.php)。同一群組很多份 bundle 欄位相同時(例如一堆訊息樣板都有 subject、content),可以改放群組層的 resources/style/{群組}.php(例如 resources/style/i18n/template.php),群組內每份 bundle 都套用;bundle 自己的型別表有列到的 key 以它為準,以 key 為單位整筆取代,不逐屬性合併。欄位標題同理:先找 resource.{群組}/{name}.{key},找不到再找群組層的 resource.{群組}.{key},都沒有才退回 key 本身。

值是陣列的 key 預設不出現在資源後台,型別表把它宣告成 json 才會列出;再加上 'presentation' => 'drive-image'(或 drive-file)就是圖片/檔案欄位,存檔時從雲端硬碟選的檔案轉成檔案路徑(與 CRUD 表單相同,見 FileService::entry()),送空清單就回到檔案預設值。例如 'logo' => ['type' => 'json', 'presentation' => 'drive-image'],bundle 檔案裡的預設值寫 []。

機密欄位的實際值永遠不離開伺服器——data 回傳的是遮罩 ••••••••(沒設定過則是空字串),default 與 placeholder 一律空白。寫回時遮罩值代表「不變更」,送新值才覆寫,送空字串才清除。出貨已標的是 gmail.password、telegram.bot-token、telegram.webhook-secret、webpush.private-key、mitake.password、google-translate.api-key、gemini-translate.api-key、ip2location-bin.download-token、ip2location-webservice.api-key、captcha-recaptcha.secret、captcha-turnstile.secret;自訂 bundle 裡的憑證要自己標,漏標的症狀是那把密鑰以明文出現在後台 API 回應裡。

這一層跟 matrix.resource-cfg 的 bundle 白名單是兩件事:白名單決定「這個 bundle 能不能編輯」,secret 決定「bundle 裡哪些 key 的值看得到」。有了後者,含憑證的混合型 bundle 才能安全地進白名單。

合併是逐 key 遞迴的(排前的覆蓋排後的、key 對 key),所以覆蓋一個選單節點時,低優先層那個節點多出來的 key(icon、tag 之類)會滲進合併結果,沒辦法個別移除。要整個移除一個節點,把該 key 的值設成 null —— 節點消失、端點對所有人 403、群組離開權限樹。但移除節點只擋端點與新授權:已經授出的權限仍然有效,因為授權檢查讀的是 base_user.permissions / base_group.permissions 存的 JSON,不是選單樹。

開一個自己的功能

以一個 Widget 為例,六個步驟,少一個都不會動。

資料表已經 migrate 好的話,可以先跑 matrix:make-crud {table} 偷懶:它會反向產生步驟 2、3、4 的 Model/Declaration/Controller 草稿,以及步驟 6 欄位標題用的 resources/i18n/{locale}/model/{table}.php。它是輔助草稿工具,不是黑盒——呈現方式、--title、--parent 猜不到或猜錯風險高的地方一律留白或印出警示,產生後務必自己核對一遍再送出,路由/選單/選單翻譯仍要照下面步驟 5、6 手動加。細節看主控台指令。

1. 資料表

Schema::create('base_widget', function (BaseBlueprint $table) {
    $table->primaryKey();                    // 抽 base_id 共用序列
    $table->text('title');
    $table->integer('category_id')->nullable();
    $table->ranking();                       // 抽 base_ranking 共用序列,要拖曳排序才需要
    $table->schedules();                     // enable_time / disable_time,需要上下架才用
    $table->auditings();                     // create_time / update_time / creator_id / updater_id
});

型別提示寫 BaseBlueprint 而不是 Blueprint,否則靜態檢查看不到那幾個自訂方法。

主鍵一定要走 primaryKey()。 用 $table->id() 會拿到從 1 開始的 bigint,而 id 1 是 ROOT —— 稽核紀錄的歸屬會靜默錯亂。

2. Model

class Widget extends BaseModel {

    protected $table = 'base_widget';

}

3. 欄位宣告 —— 沒有它,CRUD 端點會 500

#[Declared(WidgetDeclaration::class)]
class Widget extends BaseModel { }
class WidgetDeclaration implements Declares {

    /**
     * @return array<string, Definition>
     */
    public function definitions(): array {
        return array_merge(
            Definitions::primaryKey(),
            [
                'title' => Definition::text(),
                'category_id' => Definition::integer()
            ],
            Definitions::schedules(),
            Definitions::auditings()
        );
    }

    public function metadata(): Metadata {
        return new Metadata('widget', 'title');
    }

}

Metadata 的第一個參數是 alias,它必須等於選單節點的路徑前綴;第二個是「這一列叫什麼」的欄位(麵包屑與排序頁會用)。第三個參數可以指定父層關聯,巢狀資源才需要。第四個參數 ranking 可指定排序用的欄位名稱,CrudController 會用它推導預設的 $sorting/$sortable;第五、六個參數 enable/disable 標記上下架時間欄位,接上後解鎖 POST admin/schedule/toggle(單筆立即切換)與 admin/{prefix}/arrange、arrange/save(拖曳式批次上下架)兩支既有 API;schedule/toggle 是共用端點,只檢查模組選單有註冊 update 節點(最高權限帳號也一樣)與使用者的 update 權限,不經 controller 的 guard,存檔時才檢查的業務規則要放在 model 事件;也會讓清單自動提供「上下架」篩選(見給前端),匯出吃同一個篩選。

沒有 #[Declared] 的 model 一進 CRUD 端點就是 undeclared-model。

JSON 欄位形狀依情境動態變動,用 Definition::composite()。

'data' => Definition::composite('announcement-data')

參數是一個 group 名稱,對應 config('matrix.variants.{group}'):

'variants' => [
    'announcement-data' => [
        'driver' => AnnouncementTypeResolver::class,
        'banner' => BannerFields::class,
        'text' => TextFields::class
    ]
]

driver 指向你自己實作的 TypeResolver(resolve(?Model $model, mixed $input): ?string),負責當下算出一個「type 字串」;其餘 key 是「type 字串 → 子欄位定義 class」的對照表,每個子欄位定義 class 實作 Variant(格式跟 Declares::definitions() 一樣,但不用寫 metadata())。

這裡刻意用 config 而不是 cfg。 別的 driver(驗證碼、簡訊、翻譯、地理位置)都放 cfg,因為那些是營運要換的設定;variant 的值是要被實例化的類別名稱,而 cfg bundle 只要進了 matrix.resource-cfg 白名單就能從資源後台編輯 —— 類別接線不該是線上可改的東西。子欄位的標題仍然走 i18n(model/{group}.php),那才是該線上編輯的部分。

前端不會知道這是一個 composite —— 引擎把它拆成一般欄位送出去。 一個 composite 在表單端點(new / get / insert / update)會被換成每個子欄位一個 column,名字是 {欄位名}__{子欄位名}:

columns:  … , data__layout, data__lead, …
data:     … , data__layout: null, data__lead__tw: null, data__lead__en: null, …

translatable 的子欄位就是一個 translatable: true 的普通 column,前端照它既有的規則展成 __{locale}。送出時 body 也是這個扁平形狀,引擎存檔前折回巢狀 JSON —— 資料庫存的東西沒有變,仍然是 {"layout": "wide", "lead": {"tw": …, "en": …}}。

因此:

  • {欄位名}__ 是保留前綴。真的有一個叫 data__layout 的實體欄位就會撞,引擎不會警告。
  • 子欄位的標題查 resources/i18n/{locale}/model/{group}.php(不是 model/{資料表}.php),{子欄位名}:placeholder / :hint / :remark 同一份。同一個 group 的所有 type 共用這一份,所以同名的子欄位會拿到同一個標題;要讓某個 type 的子欄位有自己的說法,再放一份 model/{group}-{type}.php,它逐 key 覆蓋共用那份(跟 model/default.php 與 model/{資料表}.php 的關係一樣),沒寫到的 key 落回共用層,檔案不存在就只剩共用層。
  • 子欄位可以宣告 section、group、required、rule、options、presentation,全部照一般欄位生效。
  • 清單與匯出不展開,composite 只在表單情境有意義。

這個欄位不需要對應到任何真實的 DB 欄位存 type 值——TypeResolver::resolve() 要依據什麼判斷(另一個欄位的值、目前操作者、資料庫查詢……)完全是你的邏輯,引擎只在需要知道「這次要用哪個 variant」的當下呼叫它。沒有設定 driver、或算出來的 type 沒有對應的 Variant 時,這個欄位一個 column 都不會出現,送出時也不驗證、不寫入,原值保留。只支援一層,子欄位不能再是 Definition::composite()。

要讓 new 開在某個 type 上(例如讓使用者先選型別再進表單),把它當 body 送給 new 即可:POST admin/block/new 帶 {"type": "banner"}。引擎會依它展開子欄位,並把已宣告且可寫的輸入值反映回回應的 data,表單一開就是填好的。呼叫端亂塞的鍵不會被回吐。

套件自己宣告了四個 group,而且全部預設不接線。 沒有在 config('matrix.variants') 接上以前,那個 data 欄位在後台一個 column 都不會出現,api/common/* 則把儲存值原樣吐出來(沒存過是 {})。

group 欄位 resolve() 能依據什麼
page-data base_page.data 頁面沒有 type 欄位。通常回傳固定字串(全站共用一組欄位),或依 path 分流
menu-data base_menu.data 選單沒有 type 欄位。依資料判斷,例如 parent_id 是否為 null(頂層與子層用不同欄位)
block-data base_block.data 直接讀 type 欄位;new 時讀 body 的 type
block-item-data base_block_item.data 查父區塊的 type,但對到另一組 Variant,不是父區塊那一組

resolve() 的第二個參數只有 new / insert / update 拿得到值。 get(編輯頁)、api/common/page、api/common/menu 一律傳 null。所以 page 與 menu 的 driver 必須只靠 model 或常數就能決定 —— 只看 $input 的寫法會在編輯頁與前台端點失效。巢狀路由的 new 與 insert 在呼叫 resolve() 之前就已經把路由上的父層外鍵填進 model(例如 menu/{parent_id}/children/insert 的 parent_id),body 不必另外帶。

區塊與子項目的後台另外有這些規則:

  • 型別只收 options/block-module 清單裡的值,page-content(Block::PAGE_CONTENT)除外 —— 它標記頁面固定內容在區塊序列中的位置,只能由 seeder 預建;已有的 page-content 可以編輯、排序、上下架,但複製、刪除回 page-content-locked。
  • 新增表單的預設值:block-data 的 Variant 同時實作 Presets(defaults(): array,子欄位名 => 值)時,new 會把它們以 data__{子欄位} 補進沒帶的輸入,只影響表單、不影響驗證。
  • data 在驗證規則之外還會檢查(BlockDataGuard,新增與修改都會):BundleOptions 選單的值必須在選項內(空白交給 required),DriveImage 限 JPG / PNG / GIF / WebP / SVG(不限大小)(validation.image-invalid)。
  • 區塊複製連同子項目,複本排在最後、預設隱藏,連帶複製的子項目沿用來源子項目的上下架狀態。區塊與子項目的 copy 節點(page/{page_id}/block/{id}/copy、page/{page_id}/block/{block_id}/item/{id}/copy)由套件選單直接登記,和其他資源預設不開 copy 不同;宿主不要複製時在選單設為 null。
  • 只有父區塊型別有註冊 block-item-data 的區塊有子項目,其餘整組子項目端點回 404 data-not-found。子項目表單不顯示上下架,新增與複製後預設隱藏,由清單上架。

page-data 的最小接線是一個回傳常數的 driver:

// config/matrix.php
'variants' => [
    'page-data' => [
        'driver' => PageTypeResolver::class,
        'standard' => StandardPageFields::class
    ]
]

// app/Support/PageTypeResolver.php
class PageTypeResolver implements TypeResolver {

    public function resolve(?Model $model, mixed $input): ?string {
        return 'standard';
    }

}

menu-data 形狀一樣,差別只在 driver 讀 model:

public function resolve(?Model $model, mixed $input): ?string {
    return $model?->getAttribute('parent_id') === null ? 'root' : 'child';
}

子欄位標題分別放 resources/i18n/{locale}/model/page-data.php 與 model/menu-data.php。

選單項目可以鎖定。 menu-data 的 variant 實作下列標記介面(都沒有方法)時,Menu 的 model 事件會擋下後台對該項目的操作,回 menu-locked(Leaf 回 menu-depth-exceeded)。Leaf 用來限制層數:把最後一層的 variant 標成 Leaf,那一層就不能再往下新增。擋在 model 事件,所以 {id}/update、delete、{id}/copy、arrange/save、schedule/toggle 都涵蓋:

介面 擋下 仍可以
Leaf 在它底下新增子項目(menu-depth-exceeded) 其他操作
Locked 刪除 其他操作
Synced(繼承 Leaf、Locked) 刪除、新增(含複製出來的複本)、修改 ranking 與稽核欄位以外的任何欄位(含上下架時間)、在它底下新增子項目 排序(arrange/save 只改順序)

MenuChildrenController 另外處理畫面:Synced 項目的編輯頁所有欄位唯讀(複合欄位的子欄位跟著唯讀)、不出現儲存按鈕;Leaf(含 Synced)項目在清單上不列子項目數(不能點進下一層);Locked(含 Synced)項目的那一列沒有刪除按鈕;arrange 回傳的列與清單的列(清單只在有上下架欄位時)多一個 fixed(true = 只能排序、不能改上下架,前端的啟用開關停用),其餘列為 false。其他 controller 要同樣的效果,在 onArrange / onList 對 ArrangeService / ListService 呼叫 fixed(fn (Model $row): bool => …)。

資料由系統同步時(例如依其他資料表自動建立、更新、刪除選單項目),把寫入包在 MenuLocks::release() 裡就不受上述限制:

MenuLocks::release(function () use ($menu): void {
    $menu->title__tw = $category->title__tw;
    $menu->save();
});

4. Controller

class WidgetController extends CrudController {

    protected string $model = Widget::class;

    protected ?array $lists = ['title', 'category.title=category_title', 'enable_time'];

    protected array $updates = ['*title', 'category_id:select', 'enable_time', 'disable_time'];

}

* 開頭代表必填。! 開頭代表唯讀。+ 開頭代表虛擬。= 開頭代表鎖定(見下段)。= 夾在中間是別名(alias=source)。. 走關聯。: 後面是型別或呈現方式。來源裡的 {locale} 在解析時換成當前語系,用來取多語欄位的單一語系值而不展成多語欄位(例如 '!tour_name=session.tour.name__{locale}')。

虛擬欄位(+ 前綴或宣告 virtual: true)預設不能篩選、不能排序(op 為 null、sortable 為 false),因為資料庫沒有這個欄位。要讓它出現在搜尋條件,明寫 op(例如 ['name' => '+contact_name', 'op' => 'contains']),並在覆寫的 list() / export() 從 filters 取出這個條件自己套用 —— 留在 filters 裡的虛擬欄位條件會被照一般欄位查詢而失敗。

鎖定(=)是「可寫但不可編輯」。 值在表單開啟之前就決定了(例如區塊的型別由型別選擇器決定),所以它照樣驗證、照樣寫入,但 columns[] 上的 writable 是 false,前端據此把輸入框停用。readonly(!)是另一回事:不驗證、不寫入。鎖定同時隱含必填 —— 值既然是先決定好的,就不該是空的;要「鎖定但可為空」請用陣列語法 ['name' => 'x', 'locked' => true]。

清單回傳的是「欄位池」,$lists 只決定預設顯示哪些。 欄位池 = 清單欄位($lists,沒設就照 listing() 的退回規則)加上編輯欄位($updates 或自動推導):清單欄位依 $lists 的順序排在前面,其餘編輯欄位依編輯欄位的順序接在後面;同名欄位用清單欄位的定義。清單欄位裡若有經由 belongsTo 關聯取值的欄位(例如 category_title=category.title),它會取代編輯欄位中該關聯的外鍵(category_id),外鍵不再另外進欄位池(同一個關聯有多個清單欄位時,由第一個取代),columns[] 上的 replaces 標出被取代的外鍵名稱(其餘欄位為 null),前端排列表單時用它把清單欄位對應回表單的外鍵欄位。只在編輯欄位裡、清單畫不出來的欄位不進欄位池:Composite、Password、Hidden、自訂呈現、虛擬(+;明寫了非 null op 的虛擬欄位除外,那是專供篩選的欄位,例如 ['name' => '+receipt_time', 'type' => 'datetime', 'op' => 'between'],會進欄位池成為搜尋條件,值由 guard 填、篩選要由 controller 在呼叫 parent 前從輸入取出自行處理,否則框架會把它當成本表欄位下條件)、model $hidden 裡的欄位、有設定區塊(section)的欄位、沒宣告呈現方式也沒有選項的 JSON,以及巢狀資源的父層外鍵(例如 faq-category/{category_id}/items 的 category_id:巢狀清單裡它永遠是目前的父層,$updates 明列它只為了讓編輯表單能搬移父層,每列資料仍照常帶這個外鍵);清單欄位本身一律保留。columns[] 上的 default 標出哪些屬於清單欄位,使用者沒有 preference 時前端只顯示這些;欄位池裡的欄位能不能篩選、排序,依各欄位的 op 與 sortable。後端不解讀 preference,每次都 select 整個欄位池。編輯頁(get)與新增頁(new)的回應也帶同一份 preference(column:{key},沒登入身分或沒設定時為 null),前端據此排列表單欄位的順序。

要調整「編輯欄位」這一半,覆寫 optionals()。 它預設回傳編輯欄位,清單與匯出的欄位池都從它來,例如改掛列表用的選項來源(['name' => 'staff_id', 'options' => …])或補進稽核欄位:return [...parent::optionals(), 'update_time'];。稽核欄位(Definitions::auditings())雖然帶 section: 'other',由 optionals() 明確放入時仍會進欄位池(default 為 false,使用者自行勾選才顯示);其他有區塊的欄位照舊排除。creator_id / updater_id 放進來只會是裸 ID,要顯示帳號請用 creator=creator.username。

匯出沿用同一個欄位池。 篩選與排序只接受欄位池裡的欄位;只出現在 $exports 的欄位只輸出、不能篩選或排序(否則 $exports 列了 $hidden 欄位時,可以用篩選或排序推出值)。查詢只 select 輸出欄位與實際用到的篩選、排序欄位,所以 cell() 回呼拿到的那一列也只有這些欄位。輸出欄位:有宣告 $exports 就用 $exports;否則請求帶了 columns: [...] 且與欄位池有交集,就輸出交集、順序照請求(前端傳目前畫面上顯示的欄位);否則輸出清單欄位,順序照清單欄位。

編輯頁(get)會自動在「其他」區塊唯讀顯示稽核欄位,不用設定。 只要宣告有 Definitions::auditings(),onGet() 就在 $updates(或自動推導的欄位)後面依宣告順序補上 creator、!create_time、updater、!update_time(auditings(false) 只補前兩個)。creator / updater 是關聯欄位 creator=creator.username、updater=updater.username:BaseModel 內建 creator()、updater() 兩個關聯(BelongsTo 到 Operator,即 base_operator),直接 join 出帳號名稱,以唯讀(前端停用)的文字欄位呈現。Definitions::auditings() 本身帶 section: 'other',關聯欄位則用陣列語法的 section 鍵指定區塊。新增頁、存檔(insert / update)不含這些欄位,送上來的值會被忽略。$updates 已經放了同名欄位時以 $updates 為準;同名欄位只取第一個,所以在 parent::onGet() 之後再 columns() 同名欄位不會生效。不想要就覆寫 onGet() 且不呼叫 parent::onGet()。標題在套件的 model/default.php。清單要顯示建立者,寫 creator=creator.username;直接寫 creator_id 會因為有 creator() 關聯而自動變成下拉選項,把整張 base_operator 讀進來。

匯入

前端讀 xlsx、送 JSON(後端不碰檔案格式),每一列建一筆新資料,走與新增相同的驗證、assign、guard、save。全部列處理完才回應;任一列有錯就整批退回,回應列出每一列的列號與出錯欄位(見給前端)。

class WidgetController extends CrudController {

    use ImportsRows;

    protected ?array $exports = ['title', 'category_id'];

    // null 時退回新增表單欄位($inserts,沒設再退回 $updates)
    protected ?array $imports = ['title', 'category_id'];

    protected function onImport(ImportService $service): ImportService {
        return parent::onImport($service)->guard(...)->lookup('category_id', fn (string $text, int $row): mixed => ...);
    }

}
  • controller 要 use ImportsRows(MatrixPlatform\Http\Controllers\Admin\Concerns\ImportsRows),才有 import 與 import/template 兩支端點;$imports、onImport() 留在 CrudController,沒 use 時宣告了也不會生效。
  • 開關就是選單:{prefix}/import 與 {prefix}/import/template 兩個節點(tag 用 insert)。兩個要一起加:按鈕只看 import 節點,漏了 import/template 時按鈕照樣出現,開窗卻 permission-denied。import/template 節點的標題是範本檔名。預設 pageActions 已含 import。
  • onInsert() 的 guard 不會沿用,匯入要在 onImport() 重掛,否則商業規則會被繞過。
  • 可匯入欄位:非虛擬、非 join、非唯讀,且不是主鍵、Json、多選、雲端硬碟、密碼、Hidden、Composite;巢狀資源(非 standalone)的外鍵也排除,一律取路由參數。其餘欄位若出現在 $imports 直接略過。被排除的欄位裡有必填的(巢狀外鍵除外)→ import-column-unsupported,這個 model 無法以匯入建立。
  • 多語欄位一律展開全部語系,表頭同匯出(backend.export.locale-column)。
  • 轉換:值先 trim;空白 → null(必填得 required),布林欄空白 → false;有選項的欄位以標題完全比對反查 id(查不到或同名兩個以上 → in,不默默取第一個);Date / DateTime 嚴格依 matrix.date-format / matrix.datetime-format(擋 2026-02-31)。lookup($name, fn) 取代該欄的選項反查,可用 invalid() 回自訂代碼,空白值不會呼叫。
  • 往返:只有明寫 $exports 與 $imports 且兩者一致時,匯出檔才能直接拿來匯入;預設匯出的是使用者選的清單欄位(關聯欄位是標題欄而非外鍵),請從範本開始填。
  • 更新既有資料:locate(fn (Builder $query, array $values, int $row): Model => ...) 把匯入改成「每列更新一筆既有資料」,不再新增。$query 已套父層與 scope(巢狀資源同樣先驗父層鏈);$values 是整列原始值,含不在可匯入欄位裡的鍵(用來放對應鍵,例如編號欄)。找不到時在 callback 裡 invalid($name, $code),當成該列錯誤。只驗證、寫入有填的儲存格(唯一規則排除這一筆),空白儲存格保留原值(布林欄也不會變 false),所以無法用匯入清空欄位;可匯入欄位全空白的列直接跳過、不呼叫 callback、不計數。guard 的第二個參數是更新前的 toArray()(同 update)。範本仍只有可匯入欄位,對應鍵欄與預填列由 controller 自己補(覆寫 importTemplate(),見下一點)。
  • 跨 model 匯入(在 A 的頁面匯入 B)沒有框架支援:覆寫 import() / importTemplate(),自己建 (new ImportService(B::class))->standalone(true)。
  • 覆寫 import() / importTemplate() 要改名引入並重標 #[Action]。 方法來自同一個類別 use 的 trait,不是父類別:覆寫後 trait 版本被蓋掉,parent::importTemplate() 不存在,路由也找不到 #[Action] 而靜默消失。寫法是 use ImportsRows { importTemplate as baseImportTemplate; },再宣告 #[Action('import/template')] public function importTemplate(...),內部呼叫 $this->baseImportTemplate($request)。

暫存子項目

子資源(例如城市底下的區域)可以直接在父表單裡新增、修改、刪除、排序。異動先暫存在頁面上,按下父表單的儲存才與父資料一起寫入,在同一個交易裡,失敗就全部不生效。父 controller use StagesChildren(MatrixPlatform\Http\Controllers\Admin\Concerns\StagesChildren),再宣告哪些子資源走這個模式;子 controller 不必 use:

class CityController extends CrudController {

    use StagesChildren;

    protected function staged(): array {
        return ['areas' => 'city/{city_id}/area']; // body key => 子資源前綴
    }

    protected function validateStaged(string $key, Model $parent): void {
        // 全部子資源都寫完、排好序後才呼叫,跨列與跨子資源的規則放這裡
    }

    protected function afterStaged(string $key, Model $parent): void {
        // 驗證都通過後才呼叫,處理副作用
    }

}
  • 只有 use StagesChildren 的 controller 才有暫存模式,也才會註冊下面四支端點。trait 覆寫 CrudController 的 persist()(insert() / update() 的寫入掛點),所以父 controller 自己覆寫 insert() / update() 並呼叫 parent:: 時照樣會處理暫存子項目。
  • 宣告的是子資源前綴,不是 class。 框架用 ActionRoutes::controller($prefix) 找出掛在這個前綴上的子 controller,權限也用同一個前綴檢查(AdminPermission::permits($prefix, $tag))。子 controller 必須用 ActionRoutes::mount() 掛上、不是 standalone,且子 model 的 metadata 要有父層(外鍵靠它推導),否則丟 invalid-staged-child。key 與父表單欄位同名會丟 staged-key-conflict(存檔時 body 會互相覆蓋)。這兩項只在用到暫存模式時才檢查:打下面四支端點,或存檔的 body 帶了宣告的 key。
  • 子 service 一律由子 controller 的 onInsert() / onUpdate() / onDelete() / onSort() 建立,子資源原本的欄位與 guard 都沿用。params 是父路由參數去掉 id,加上 [外鍵 => 父 id];新增頁沒有父 id,不放外鍵。
  • 子 controller 覆寫的 insert() / update() 不會被呼叫,框架只呼叫 service。副作用請掛在 guard 或父 controller 的 afterStaged()。父 controller 覆寫 insert() / update() 而不呼叫 parent:: 的話,暫存子項目也不會被處理。
  • 子資源的 options、guard、onX 不可依賴 request() 讀子資源的路由參數。 暫存模式下的 request 是父資源的路由(city/5/update),request()->route('city_id') 是 null,而且不會報錯。需要父資料時改讀 app(StagedContext::class):parent 是父表單目前的值(端點的 body parent;存檔時是父 body 去掉暫存 key),model 是父 model(編輯頁,或存檔時剛寫好的父資料;新增頁的端點是 null)。

選單節點:沒有節點的路由不會註冊,所以每個用暫存模式的父資源都要登記這四支,tag 照下表。除了節點本身,還會檢查使用者對子資源前綴的權限。

節點 tag 用途 子資源權限
city/staged/{key} insert 新增頁:子表單欄位、可否排序、標題 insert
city/{id}/staged/{key} update 編輯頁:同上,另加所有既有子列 insert、update、delete 任一
city/staged/{key}/validate insert 新增頁:驗一列,不寫入 insert
city/{id}/staged/{key}/validate update 編輯頁:驗一列,不寫入 新增列 insert,既有列 update

帶 {id} 的兩支先用父 controller 的 onUpdate() 查父資料(findOrFail),範圍外回 404。讀欄位不要求子資源的 query 權限:前端完全不打子資源的列表端點;編輯頁表格要用的列表欄位,由框架直接呼叫子 controller 的 onList() 與 ListService 取得,不經路由權限。彈窗一律用表單欄位。

端點 body:{ "parent": { 父表單目前的值 }, "data": { 子列目前的值 }, "id": 5 }。

  • id 只在針對既有子列時帶:讀欄位時回傳那一列的 onUpdate() 欄位,驗證時走 update;不帶就是新列,用 onInsert() 欄位。
  • data 用來決定 variant / composite 欄位要展開哪一型(Variants::type() 收到的 input)。
  • 欄位端點回 { title, sortable, abilities, columns, rows }:title 是子資源前綴的選單標題,sortable 照子 controller 的 $sortable,abilities 是 { insert, update, delete } 三個布林(見下方「子資源自訂寫入」;沒實作時三項都是 true),columns 與 new / get 的 columns 同形,rows 是既有子列的表單格式資料(同 get 的 data,含 composite 攤平;子資源可排序時依 ranking、再依主鍵排序),新增頁固定是 []。
  • 編輯頁另回 list: { columns, sorting },是子資源列表信封(list 端點)的同名欄位,前端用來畫表頭(與子列表頁相同的欄位)。列表用子 controller 的 onList() 準備,params 同上、不分頁(size: 0),page / row actions 一律清空不用。列表列的值依主鍵併入 rows 的每一列:表單格式原樣保留,列表值補上,同名鍵以表單值為準(表單值是存檔要送的)。新增頁沒有父 id,查不了列表,不回 list,前端改用表單欄位畫表格。
  • validate 流程比照 insert / update(驗證、assign、guard),只是不 save(),而且整段在一個 savepoint 裡最後一律 rollback(雲端硬碟參照會寫 base_file)。新增頁沒有父 id,不 attach,guard 看到的外鍵是 null。通過回 [],失敗是一般的 422;帶 fields 的其他 ServiceException 也改成 422 validation-failed,fields 原樣保留。子 controller 實作 WritesStagedRows 時,編輯頁的 validate 改成實際寫入後 rollback,並回傳試算資料(見下方)。

存檔:照樣打 insert / {id}/update,body 多帶宣告的 key。body 沒帶任何宣告的 key 時,行為與一般存檔完全相同。

{
  "name": "台北",
  "areas": {
    "rows": [{ "id": 5, "name": "大安" }, { "name": "信義" }],
    "delete": [7],
    "order": [0, "id:3", 1]
  }
}
  • rows 只放有新增或修改的列:有 id 是修改,沒有是新增。修改也是全量覆寫,列要帶完整的表單資料。
  • order 只在子資源可排序、而且順序有變動時才送,內容是畫面上的完整順序:數字是 rows 的索引,id:3 是沒有異動的既有列。框架換成實際 id 後交給子 controller 的 onSort() 與 SortService::sort(),所以順序不完整就是 invalid-sort-order。送了 order 需要子資源的 update 權限。
  • 新增列需要子資源的 insert 權限,修改列 update,刪除 delete;不足回 403,整筆不寫入。子 controller 實作 WritesStagedRows 時,每項改看 stagedPermissionTag() 回傳的 tag(四支端點的子資源權限也一樣)。

處理順序(全部在 action 的交易裡):

  1. 寫父資料。父欄位驗證失敗時,編輯頁照樣用既有的父資料處理子列;新增頁沒有父 id,子列改用 validate 的方式檢查(不寫入),錯誤一樣累積。
  2. 依 staged() 宣告的順序處理每個 key;同一個 key 內先刪除、再修改、最後新增,每一列(每個刪除的 id)各自一個 savepoint,出錯就記下來,接著處理下一列。
  3. 這個 key 沒有錯誤才排序。
  4. 有任何錯誤,合併成一個 422 validation-failed 丟出,整筆 rollback。
  5. 全部 key 都寫完、排好序後,依序對每個 key 呼叫 validateStaged(),這時查資料庫看到的就是最終狀態;在裡面 invalid($key, $code) 會讓整筆 rollback。
  6. 再依序對每個 key 呼叫 afterStaged()。它仍在交易內,寄信、打第三方這類撤不回的副作用請包在 DB::afterCommit() 裡。

兩個 hook 只對 body 有帶的 key 呼叫。

錯誤 key:

來源 key
父欄位 原本的欄位名(name)
新增或修改的列 areas.{rows 的索引}.{欄位},例如 areas.1.name
刪除失敗(data-in-use、刪除 guard 的錯誤等) areas.delete.{id},值是錯誤代碼,例如 ["data-in-use"]
排序失敗、validateStaged() 的 invalid($key, …) areas

列的錯誤除了 422 validation-failed,任何 extra 帶 fields 的 ServiceException(例如 error('out-of-stock', 409, ['fields' => ['addon_id' => ['out-of-stock']]]))也視為欄位錯誤,照上表改寫成列的 key,不會讓整次存檔以原本的狀態碼失敗。

子資源自訂寫入:子 controller 實作 WritesStagedRows(MatrixPlatform\Http\Controllers\Admin\Concerns\WritesStagedRows),暫存模式下的新增、修改、刪除就改呼叫它的方法,不再走 onInsert() / onUpdate() / onDelete() 的 service。適合「一列怎麼寫入」有自己業務流程的子資源(例如要經過領域服務、要記金額)。排序,以及沒有父資料時的檢查(新增頁的 validate、新增頁父資料寫入失敗時的逐列檢查)仍走 service。

class AreaController extends CrudController implements WritesStagedRows {

    public function stagedInsertRow(array $row): Model {
        $city = app(StagedContext::class)->model; // 父 model
        // 業務檢查與寫入,回傳寫好的 model
    }

    public function stagedUpdateRow(string $id, array $row): Model {
        // $id 由框架傳入;要自己確認這一列屬於父資料
    }

    public function stagedDeleteRow(string $id): void {}

    public function stagedAbilities(Model $parent): array {
        return ['insert' => true, 'update' => false, 'delete' => $parent->editable];
    }

    public function stagedPermissionTag(string $operation): string {
        return $operation === 'delete' ? 'update' : $operation; // 刪除改看 update
    }

}
  • id 一律由框架傳入(body 的 rows[].id、delete),父 model 從 StagedContext::model 取。$row 是 body 原樣的列資料,欄位驗證要自己做(例如呼叫 invalid())。
  • validate:編輯頁在一個 savepoint 裡呼叫 stagedInsertRow() / stagedUpdateRow(),最後一律 rollback;after-commit 的事件隨 savepoint 丟棄,不會寄信。rollback 前用子 controller 的 onUpdate() 重新查出寫好的那一列(所以 join 的欄位也有值),轉成和欄位端點 rows 同格式的單列資料,再用 onList() 的列表查出同一列併入列表值(規則同欄位端點的 rows)後回傳,前端合併進暫存列,用來顯示金額這類由後端算出的值。寫入方法丟出帶 fields 的 ServiceException(例如 409 out-of-stock)時,validate 一律改回 422 validation-failed,fields 原樣保留(只驗一列,不加 key 前綴),與存檔的行為一致。
  • abilities:只在編輯頁由 stagedAbilities($parent) 決定,給前端隱藏按鈕用;新增頁三項都是 true。存檔時不據此擋,後端靠權限 tag 與各列自己的業務檢查。
  • 權限 tag:stagedPermissionTag() 收到 insert / update / delete,回傳要檢查的子資源選單 tag。沒實作介面時就是原本的 tag。

data-not-found(id 不屬於這個父資料)、invalid-drive-file 這類不屬於欄位驗證的錯誤不改寫 key,整筆直接失敗,照一般錯誤回應。

已知限制見暫存子項目的取捨。

5. 路由 —— 必須掛在 admin 前綴之下

Route::middleware(['encrypted-api', 'envelope-api', 'locale-api'])->group(function () {
    Route::prefix(config('matrix.admin-api-prefix'))->group(function () {
        Route::middleware(['user-api', 'permission-api'])->group(function () {
            ActionRoutes::mount('widget', WidgetController::class);
        });
    });
});

前綴不對,對不上選單節點:ActionRoutes::mount() 一支都不會註冊(全部 404),自己用 Route::post() 掛的則被 AdminPermission 擋成 403。

encrypted-api 漏掉的話,那一組路由不會解密,但前端還是會加密。 前端是按前綴決定要不要封信封的(它不知道哪些路由掛了什麼 middleware),所以你自己的 admin 端點只要少掛這一層,收到的 body 就是 {kid, epk, iv, ts, ct} 這個信封本身,症狀是每一個欄位都 validation-failed。這三個別名的順序也是約束:encrypted-api 必須在 envelope-api 之前,否則錯誤信封不會被加密。

6. 選單與翻譯

resources/menu/app.php(然後把 admin-menus 改成 'app base'):

'catalog' => ['icon' => 'fa-solid fa-box', 'ranking' => 300, 'parent' => null],

    'widget' => ['icon' => 'fa-solid fa-cube', 'ranking' => 100, 'parent' => 'catalog', 'group' => true, 'tag' => 'query'],

        'widget/{id}' => ['parent' => 'widget', 'tag' => 'query'],
        'widget/{id}/update' => ['parent' => 'widget', 'tag' => 'update'],
        'widget/delete' => ['parent' => 'widget', 'tag' => 'delete'],
        'widget/insert' => ['parent' => 'widget', 'tag' => 'insert'],
        'widget/new' => ['parent' => 'widget', 'tag' => 'insert'],

resources/i18n/en/menu/app.php 放對應標題,resources/i18n/en/model/base_widget.php 放欄位標題:

return [
    'title' => 'Name',
    'title:placeholder' => 'Enter a name',
    'category_id' => 'Category',
];

欄位標題找不到時的行為是「看起來正常但可能不對」 —— 先退到 model/default.php 的通用標籤(create_time 之類的共用欄位都在那裡),再退到字面 {title}。所以漏翻譯不一定看得出來。

前台身分(member / vendor)

套件只提供零件,整個流程歸你:

事實 說明
沒有登入端點 自己用 AuthToken::issue() + IdentityToken::attach() + login-throttle-api:{bundle} 組
你的 member / vendor 資料表必須用 primaryKey() 用 $table->id() 會拿到 id = 1 的第一筆 —— 而 id 1 是 ROOT,稽核歸屬會靜默錯亂
那兩張表也必須帶 auditings() 四個欄位 稽核軌跡與建立者推導都靠它們
登出的語義、驗證碼生命週期、密碼規則 全部由你決定。要驗證碼的話不用自己接 driver:app(CaptchaService::class)->driver('captcha-turnstile') 給你一個 driver,generate() 發、verify() 收。bundle 名是必填的——套件沒有替前台準備設定鍵,要嘛寫死,要嘛自己開一個 config 讓它可調。解不出 driver 一律 invalid-captcha-driver,不會靜默放行

訊息

送訊息

MailService、SmsService、PushService、TelegramService 共用同一組公開介面(定義在 MessageService):schedule()、send()、resend()、cancel()。

app(MailService::class)->schedule(now()->addHour(), 'alice@example.com', 'welcome', ['name' => 'Alice']);
app(SmsService::class)->send('0912345678', 'otp', ['code' => '123456']);
app(PushService::class)->send('42', null, [], ['provider' => 'webpush', 'title' => '通知', 'content' => '你有一筆新訂單']);
app(TelegramService::class)->send('42', null, [], ['provider' => 'telegram', 'content' => '你有一筆新訂單']);

app(MailService::class)->resend($id);
app(MailService::class)->cancel($id);

schedule($at, $to, $template = null, $vars = [], $options = []) 回傳寫進 base_mail_log / base_sms_log / base_push_log / base_telegram_log 的那一列;send($to, $template = null, $vars = [], $options = []) 是 schedule(now(), ...) 的薄封裝,等同「立即送」。樣板檔案放 resources/i18n/{語系}/template/{name}.php。resend(int|string $reference) 複製一筆既有紀錄(清空 send_time / response / error、schedule_time 設回 now())重新排入佇列,原始那筆不受影響;cancel(int|string $reference) 只對還是 Scheduled 的紀錄生效,把它改成 Failed(error 設為 'cancelled'),對已經是終端狀態(Success / Failed)的紀錄呼叫是冪等的,不會報錯也不會改動它。

事實 說明
回傳的列是 Scheduled,不是已送出 真正的送出在 worker。schedule() 只負責寫列 + 通知有東西要送
$at 到期了才派工 未來時間只寫列,等 messages:dispatch 那一輪撈到
樣板可以是 null,provider 不行 樣板要嘛自己指名 provider,要嘛 $options 給。都沒有就 invalid-message-provider;供應商沒設 driver 就 message-provider-has-no-driver
$options 覆蓋樣板的渲染結果 只認 provider、subject、title、content、data、cc、bcc 七個 key。值是 null 不算覆蓋。title 只有 push 用;data 給 push(額外 payload)與 telegram(parse_mode 等 Bot API 選項)用;mail / sms 不讀這兩個 key。telegram 的 data 蓋不掉 chat_id 與 text——收件目標由訂閱解析與 sandbox 決定,內容一律是記錄下來的 content,否則 base_telegram_log 會對不上實際送出的東西,sandbox 也會被繞過。cc/bcc 只有 mail 用,可以是陣列或以逗號/分號/空白分隔的字串(樣板裡的 cc/bcc 欄位同樣有效,$options 給了就整個取代),去重後以 , 串接寫進 base_mail_log.cc/bcc,沒有就是 null;resend() 會一起複製
$options['locale'] 指定取哪個語系的樣板 沒給就用當下的 app()->getLocale()。樣板只放在某一個語系(例如只維護繁中一份)時,前台其他語系的請求觸發寄送會 message-template-not-found,這時要帶 locale。紀錄的 locale 欄位寫的是實際取樣板的語系
$options['attributes'] 寫入額外欄位 給消費端在紀錄表自己加的欄位用(例如關聯的訂單 id),[欄位 => 值] 原樣寫進新的那一列。套件自己寫的欄位(provider、receiver、content、template、schedule_time、locale、sender、cc、bcc、subject 等)一律以套件為準,蓋不掉。resend() 複製整列,所以這些欄位也會帶到新的那一筆
mail 的寄件者是當下快照 sender 寫入時從 cfg('{provider}.from-address') 取,之後改設定不影響已排程的訊息
resend() 的 ip 是重發當下的 IP 複製出來的新紀錄一樣會經過 creating 的 ip generator,存的是操作 resend 的人的 IP,不是原始訊息建立時的 IP

派送與 worker

事實 說明
一個發送工作只送一封,節奏由 worker 的序列性決定 工作的單位是 channel。它撈這個 channel 最前面一筆(schedule_time 再 id)、送出、睡掉該供應商的 interval、然後派下一個工作接手;撈不到就結束,不派後繼。sleep 佔住的是那條 queue 唯一的 worker,所以兩次送出之間一定隔了 interval,不管佇列裡有幾個工作 —— 套件不記錄「上次幾點送的」,沒有任何節流狀態。messages:dispatch 只是鏈條斷掉時的安全網(EXISTS 檢查,有東西在等就派一個工作)
每條 queue 最多一個 worker 節流靠的是 worker 的序列性,所以多開一個 worker 就是速率翻倍。內建的四個 channel 各有自己的 queue(messaging-mail/messaging-sms/messaging-push/messaging-telegram),要開四個 worker;漏開一條就是那條 channel 全部停在 Scheduled,而 messages:dispatch 每分鐘照樣回成功。套件不偵測 worker 存活 —— 那歸行程監管(systemd/supervisor/Horizon),你為了跑 queue:work 本來就需要它們
interval < --timeout < retry_after 套件無法幫你算這三個數字:工作在派送時只知道 channel,還不知道會送到哪個供應商。--timeout(預設 60)要大於「最大的 interval + 單筆送出耗時」,connection 的 retry_after 要再大於它
同一個 channel 嚴格 FIFO 送出順序就是 schedule_time 的順序,不分供應商。所以一個供應商的 interval 也會延後排在它後面的其他供應商的訊息
隊頭送不出去會卡住整個 channel 排程時 schedule() 就會擋掉沒有 driver 的供應商,所以這只發生在「排程之後設定才壞掉」:driver 被拿掉、改成不是 driver 的類別、或 cfg bundle 消失。工作會失敗(進 failed_jobs)、不動任何記錄,而那一筆還在隊頭 —— 後面的訊息(包含其他供應商的)都送不出去。排程每分鐘再派一次,所以壞多久就累積多少筆 failed_jobs。要隔離就給那個供應商自己的 channel(也就是自己的 queue 與 worker)
設定壞掉的失敗只在 failed_jobs 看得到 ServiceException::report() 回 false,所以那種例外不進 Laravel 的 exception handler,Sentry 那類收不到
送出失敗會寫一筆 Log::error 這裡說的是 driver 真的被呼叫之後才失敗:記錄標成 Failed,鏈條照樣往下走。事件名 messaging.{channel}.failed,context 帶 channel、provider、訊息 id 與寫進 error 欄位的內容。error 可能含供應商回應(response 欄位本來就存完整回應),log 的保存政策要照這個前提設
worker 硬掉在送出中間會重送 送出成功但寫回失敗、或行程被 SIGKILL/OOM 砍在 driver 呼叫途中時,那一列還是 Scheduled,下一個工作會再送一次。優雅重啟(SIGTERM)不受影響,Laravel 會讓當前工作跑完。套件選的是「寧可重送也不漏送」,而且沒有重試次數上限
queue connection 設成 sync 送出會變成同步執行,interval 照樣生效(在同一個行程裡睡),但鏈條會變成遞迴呼叫

後台查詢、重發與取消

mail-log / sms-log / push-log / telegram-log 四個 Admin controller 都繼承同一個抽象基底 MessageLogController,唯讀:只有列表({prefix})、查看單筆({prefix}/{id})、{prefix}/{id}/resend、{prefix}/{id}/cancel 四個動作有登記選單節點、對授權使用者開放,其餘 CRUD 動作(見上方端點表的附註)一律 403。resend/cancel 內部就是呼叫對應 MessageService 的同名方法,回傳 {"id": ...}(resend 是新那筆的 id)。查看單筆時 model 宣告的每個欄位都是唯讀(MessageLogController 建構時把它們全放進 $readonly,回應的 writable 一律 false),郵件內容以 editor 呈現,簡訊、推播與 Telegram 內容以 textarea 呈現。四個 controller 本身沒有額外邏輯,只設定各自的 $model、$service、$lists。

Push 訂閱(Member)

Push 是唯一需要前端配合的通道:瀏覽器要先用 Web Push API 取得 endpoint / keys.p256dh / keys.auth,再呼叫 MemberPushSubscriptionController 存進 base_push_subscription,PushService::send() 才推得出去。

端點 說明
api/member/push/subscribe 帶 endpoint、keys.p256dh、keys.auth。以 endpoint 為準:同一個 endpoint 再訂閱一次是更新既有紀錄,不會重複
api/member/push/unsubscribe 帶 endpoint。只刪目前這個會員自己名下、endpoint 相符的那一筆,刪不到不報錯

Service worker 註冊、Notification.requestPermission()、呼叫這兩支 API,都是前端(消費端 repo)的工作,不在這個套件範圍內。

Telegram 綁定與 Webhook

Telegram 的訂閱對象是後台使用者(User),不是前台會員(Member)——這個通道設計給營運/維運告警之類「後台人員自己訂閱通知」的情境。base_telegram_log.chat_id 是雙語意欄位,不額外加欄位區分,靠「查不查得到訂閱紀錄」解析:TelegramDriver 先判斷這個值是不是負數——Telegram 的規則是群組/頻道 chat_id 恆為負數、個人(這裡是 User)恆為正數,天然不會撞號,所以負數直接當成目標 chat_id 送(固定群組/頻道模式,send($chatId, ...) 時 $to 直接傳 chat_id 字串),連查都不用查;非負數才去查 base_telegram_subscription.user_id,查到就送去對應的訂閱 chat_id(User 訂閱模式),查不到就跟負數的情況一樣、把值本身當字面 chat_id 送。

跟 Push 用 JS 直接呼叫 subscribe API 不同,User 訂閱需要使用者主動在 Telegram 裡點一個連結、跟 bot 對話。綁定端點掛在 admin 前綴、user-api middleware 底下(跟 admin/user/preference 同層,不需要額外權限,登入即可管理自己的綁定):

端點 說明
admin/user/telegram/link 產生一次性 token(10 分鐘內有效,存在 cache,不進資料表),回傳 https://t.me/{bot-username}?start={token} 給前端導頁或顯示 QR code
admin/user/telegram/unsubscribe 刪掉目前這個使用者自己名下的 base_telegram_subscription
api/telegram/webhook Telegram 伺服器呼叫,不是給前端用的。收到 /start {token} 就把 token 換回 user id、寫入(或更新)那個使用者的 chat_id

這是套件目前唯一一個接收外部主動呼叫的端點(前面所有端點都是「我們主動對外呼叫」或「被已登入使用者呼叫」),所以認證方式也不同——不是 token,是 header:呼叫方要帶 X-Telegram-Bot-Api-Secret-Token,值要等於 cfg('telegram.webhook-secret'),對不上或這把密鑰根本沒設定,一律 403。跑 php artisan messages:telegram-webhook 之前記得先把 telegram.bot-token 與 telegram.webhook-secret 設定好(這支指令本身也會檢查,兩個空字串會直接失敗、不呼叫 Telegram)。

無效或已過期的 token 會被靜靜忽略(不是 /start 開頭的訊息也一樣),webhook 一律回 200 系列的成功——Telegram 只在乎「有沒有收到」,不在乎綁定有沒有真的成功。

自訂訊息通道

事實 說明
註冊點是 config/matrix.php 的 messaging.channels,但那是巢狀 key 宣告 messaging 會整個取代掉內建的 mail / sms / push / telegram
每個 channel 都必須宣告 queue 沒宣告就是 invalid-message-channel,那個 channel 完全不能用。加一個 channel 就是加一條 queue 加一個 worker
每個供應商還要一份 resources/cfg/{provider}.php driver 是必填 —— 沒有它 schedule() 當場回 message-provider-has-no-driver。其餘的鍵照 cfg 設定鍵裡 gmail、mitake、webpush、telegram 四組的形狀寫
供應商與樣板名稱要跨 channel 唯一 ——
樣板必須指名 provider 否則 schedule() 當場回 invalid-message-provider

偏好設定

每個身分(user / member / vendor)在 base_preference 各有一筆偏好資料。資料分兩種:

  • 宣告過的項目:寫法和系統設定一樣,用 cfg bundle 宣告。後端用 preference() 讀取,前端用 preference/settings 取得欄位與生效值。
  • 沒宣告的鍵:例如 CRUD 的 listSize、column:{key},內容由前端決定,後端照原樣存取。

套件出貨的宣告項目只有 preference-user 的 returnToList(布林,預設 true):後台表單存檔後是否返回列表頁。站台的 bundle 會與它合併。

宣告

bundle 名稱固定是 preference-user、preference-member、preference-vendor。預設值寫在 resources/cfg/,型別、rule、呈現方式寫在 resources/style/cfg/,格式與系統設定相同:

// resources/cfg/preference-user.php
return ['locale' => 'tw', 'page-size' => 20];

// resources/style/cfg/preference-user.php
return [
    'locale' => ['type' => 'text', 'presentation' => 'select', 'rule' => ['string', 'in:tw,en']],
    'page-size' => ['type' => 'integer', 'rule' => ['integer', 'min:1']],
];

欄位標題沿用系統設定的翻譯 resource.cfg/preference-user.{key}。某種身分沒有對應的 bundle,就表示它沒有宣告項目。

  • secret:三種身分都能用。後端存原值,preference() 讀到的也是原值;回給前端的 get、save、settings 一律換成遮罩 ••••••••,系統預設值也不外流(default 為空字串)。送回遮罩代表不變更,不帶 merge 的 save 也會保留原值。
  • drive-image / drive-file:只有 preference-user 能用。挑檔時依目前後台使用者的雲端硬碟權限檢查,存成檔案項目,與系統設定相同。member 與 vendor 沒有雲端硬碟,宣告了就回 invalid-preference-column。

生效值

依序取第一個有值的:

  1. 個人存的值
  2. 系統預設值:cfg bundle 的值。把 bundle 列進 matrix.resource-cfg,管理者就能在資源後台修改全站預設
  3. 檔案預設值
preference('locale');           // 目前登入的身分
preference('locale', $member);  // 沒有請求的情境(例如寄訊息)要指定身分

讀沒宣告的鍵會回 invalid-preference-key。

寫入

preference/settings/update 與 preference/save 都會對宣告項目做驗證(依 rule)與轉型(依 type)。存 null、空字串,或跟系統預設值相同的值,會移除這個鍵,之後就跟著系統預設值變動。readonly 的項目不會被寫入。

save 不帶 merge 時是整份覆蓋,所以沒帶上來的宣告項目也會回到預設值。

preference/settings 回 {columns, data, default}:columns[] 的形狀與系統設定編輯頁相同,data 是生效值,default 是系統預設值。settings/update 只收宣告項目,收到的鍵直接放在請求最外層(例如 {"locale": "en"}),回傳更新後的 settings。

參考

以下表格是契約。守門測試(tests/Feature/ReadmeTest.php)會把第一欄的識別字拿去跟實際的路由表、設定、指令、資料表對照 —— 表格寫錯或程式改了沒回來改表,測試就會紅。

端點

除了 GET api/files/{path}(公開檔案讀取,見下方說明)之外,其餘全部是 POST。路徑省略前綴(admin、api 與 vendor 分別來自 matrix.admin-api-prefix、matrix.api-prefix 與 matrix.vendor-api-prefix)。

POST encryption-key 是唯一不在任何前綴底下的端點:三組 API 共用同一個金鑰圈,而它是拿公鑰的地方,所以它不能落在任何一組的加密範圍裡——沒有公鑰就沒有信封,拿公鑰的請求自己不可能是信封。它同時也宣告了 #[Action(encrypted: false)],所以「它是明文」是寫下來的事實,不是靠它剛好落在所有前綴之外推論出來的。

GET api/files/{path} 是唯一的例外:不需要身分、不走信封,直接回 302(公開上傳檔案)、200(串流,drive-linked 檔案)或 404(私有/不存在)。路由用 Route::get() 註冊,Laravel 會自動一併掛上 HEAD(任何 Route 只要方法含 GET 就一定含 HEAD,框架內建行為,無法關閉),所以「端點」表裡這一列的方法欄是 GET\|HEAD。

方法 路徑 需要
POST admin/auth/captcha 匿名
POST admin/auth/login 匿名(有節流)
POST admin/auth/logout 登入
POST admin/auth/mfa 匿名(有節流)
POST admin/auth/mfa/confirm 登入
POST admin/auth/mfa/disable 登入
POST admin/auth/mfa/setup 登入
POST admin/auth/passkey/options 匿名(有節流)
POST admin/auth/passkey/login 匿名(有節流)
POST admin/auth/passwd 登入
POST admin/auth/profile 登入
POST admin/i18n/get 匿名
POST admin/file/upload 登入
POST admin/file/update 登入
POST admin/drive/root 登入
POST admin/drive/home 登入
POST admin/drive/group 登入
POST admin/drive/{id} 登入
POST admin/drive/{id}/children 登入
POST admin/drive/{id}/folder 登入
POST admin/drive/{id}/upload 登入
GET|HEAD admin/drive/{id}/download 登入
POST admin/drive/{id}/download 登入
POST admin/drive/{id}/publish 登入
POST admin/drive/{id}/rename 登入
POST admin/drive/{id}/move 登入
POST admin/drive/{id}/path 登入
POST admin/drive/{id}/delete 登入
POST admin/drive/trashed 登入
POST admin/drive/{id}/restore 登入
POST admin/manipulation-log/query 登入
POST admin/schedule/toggle 登入
POST admin/city 授權
POST admin/city/new 授權
POST admin/city/insert 授權
POST admin/city/{id} 授權
POST admin/city/{id}/update 授權
POST admin/city/delete 授權
POST admin/city/sort 授權
POST admin/city/sort/save 授權
POST admin/city/{city_id}/area 授權
POST admin/city/{city_id}/area/new 授權
POST admin/city/{city_id}/area/insert 授權
POST admin/city/{city_id}/area/{id} 授權
POST admin/city/{city_id}/area/{id}/update 授權
POST admin/city/{city_id}/area/delete 授權
POST admin/city/{city_id}/area/sort 授權
POST admin/city/{city_id}/area/sort/save 授權
POST admin/menu 授權
POST admin/menu/new 授權
POST admin/menu/insert 授權
POST admin/menu/{id} 授權
POST admin/menu/{id}/update 授權
POST admin/menu/delete 授權
POST admin/menu/arrange 授權
POST admin/menu/arrange/save 授權
POST admin/menu/{parent_id}/children 授權
POST admin/menu/{parent_id}/children/new 授權
POST admin/menu/{parent_id}/children/insert 授權
POST admin/menu/{parent_id}/children/{id} 授權
POST admin/menu/{parent_id}/children/{id}/update 授權
POST admin/menu/{parent_id}/children/delete 授權
POST admin/menu/{parent_id}/children/arrange 授權
POST admin/menu/{parent_id}/children/arrange/save 授權
POST admin/page 授權
POST admin/page/new 授權
POST admin/page/insert 授權
POST admin/page/{id} 授權
POST admin/page/{id}/update 授權
POST admin/page/delete 授權
POST admin/page/arrange 授權
POST admin/page/arrange/save 授權
POST admin/page/{page_id}/block 授權
POST admin/page/{page_id}/block/new 授權
POST admin/page/{page_id}/block/insert 授權
POST admin/page/{page_id}/block/{id} 授權
POST admin/page/{page_id}/block/{id}/copy 授權
POST admin/page/{page_id}/block/{id}/update 授權
POST admin/page/{page_id}/block/delete 授權
POST admin/page/{page_id}/block/arrange 授權
POST admin/page/{page_id}/block/arrange/save 授權
POST admin/page/{page_id}/block/{block_id}/item 授權
POST admin/page/{page_id}/block/{block_id}/item/new 授權
POST admin/page/{page_id}/block/{block_id}/item/insert 授權
POST admin/page/{page_id}/block/{block_id}/item/{id} 授權
POST admin/page/{page_id}/block/{block_id}/item/{id}/copy 授權
POST admin/page/{page_id}/block/{block_id}/item/{id}/update 授權
POST admin/page/{page_id}/block/{block_id}/item/delete 授權
POST admin/page/{page_id}/block/{block_id}/item/arrange 授權
POST admin/page/{page_id}/block/{block_id}/item/arrange/save 授權
POST admin/geolocation 授權
POST admin/user 授權
POST admin/user/new 授權
POST admin/user/insert 授權
POST admin/user/{id} 授權
POST admin/user/{id}/update 授權
POST admin/user/{id}/disable-mfa 授權
POST admin/user/{id}/revoke-passkeys 授權
POST admin/user/delete 授權
POST admin/user/preference/get 登入
POST admin/user/preference/save 登入
POST admin/user/preference/settings 登入
POST admin/user/preference/settings/update 登入
POST admin/user/telegram/link 登入
POST admin/user/telegram/unsubscribe 登入
POST admin/user/passkey 登入
POST admin/user/passkey/register/options 登入
POST admin/user/passkey/register 登入
POST admin/user/passkey/{id}/rename 登入
POST admin/user/passkey/{id}/delete 登入
POST admin/group 授權
POST admin/group/new 授權
POST admin/group/insert 授權
POST admin/group/{id} 授權
POST admin/group/{id}/update 授權
POST admin/group/delete 授權
POST admin/resource/cfg 授權
POST admin/resource/cfg/{id} 授權
POST admin/resource/cfg/{id}/update 授權
POST admin/resource/i18n 授權
POST admin/resource/i18n/{id} 授權
POST admin/resource/i18n/{id}/update 授權
POST admin/resource/i18n/menu 授權
POST admin/resource/i18n/menu/{id} 授權
POST admin/resource/i18n/menu/{id}/update 授權
POST admin/resource/i18n/model 授權
POST admin/resource/i18n/model/{id} 授權
POST admin/resource/i18n/model/{id}/update 授權
POST admin/resource/i18n/options 授權
POST admin/resource/i18n/options/{id} 授權
POST admin/resource/i18n/options/{id}/update 授權
POST admin/resource/i18n/template 授權
POST admin/resource/i18n/template/{id} 授權
POST admin/resource/i18n/template/{id}/update 授權
POST admin/translation 授權
POST admin/mail-log 授權
POST admin/mail-log/{id} 授權
POST admin/mail-log/{id}/resend 授權
POST admin/mail-log/{id}/cancel 授權
POST admin/sms-log 授權
POST admin/sms-log/{id} 授權
POST admin/sms-log/{id}/resend 授權
POST admin/sms-log/{id}/cancel 授權
POST admin/push-log 授權
POST admin/push-log/{id} 授權
POST admin/push-log/{id}/resend 授權
POST admin/push-log/{id}/cancel 授權
POST admin/telegram-log 授權
POST admin/telegram-log/{id} 授權
POST admin/telegram-log/{id}/resend 授權
POST admin/telegram-log/{id}/cancel 授權
POST api/common/cfg 匿名
POST api/common/city 匿名
POST api/common/i18n 匿名
POST api/common/menu 匿名
POST api/common/page 匿名
GET|HEAD api/files/{path} 匿名,不走信封
POST api/member/preference/get 登入
POST api/member/preference/save 登入
POST api/member/preference/settings 登入
POST api/member/preference/settings/update 登入
POST api/member/push/subscribe 登入
POST api/member/push/unsubscribe 登入
POST api/telegram/webhook 匿名*
POST encryption-key 匿名
POST vendor/preference/get 登入
POST vendor/preference/save 登入
POST vendor/preference/settings 登入
POST vendor/preference/settings/update 登入

「授權」= 登入 + 該選單節點的權限。user / group 的 export、copy、sort 套件出貨的選單沒有對應節點,所以不會註冊,對所有人回 404 endpoint-not-found —— 那幾個動作在套件自己的兩個 controller 上是關閉的(兩者也沒有 use ImportsRows,沒有匯入端點)。mail-log / sms-log / push-log / telegram-log 更進一步:選單只登記了列表、{id}、{id}/resend、{id}/cancel 四個節點,其餘的(new、insert、{id}/update、{id}/copy、delete、export、sort、sort/save、arrange、arrange/save)全部因為選單沒有節點而不註冊(含 ROOT 也打不到)——是刻意設計,訊息紀錄只能查詢與重發/取消,不能被手動增刪改。api/telegram/webhook 標「匿名*」是因為它不掛任何登入態 middleware(呼叫方是 Telegram 伺服器,沒有我們的 session/token 可帶),但自己驗證 X-Telegram-Bot-Api-Secret-Token header 等於 cfg('telegram.webhook-secret'),沒設定這把密鑰就對所有請求一律 403(見Telegram 綁定與 Webhook)。

設定鍵

config/matrix.php。改任何一個都要記得 mergeConfigFrom 只合併頂層 key。

鍵 出貨值 說明
matrix.admin-api-encryption true admin 前綴要不要傳輸加密。三組前綴各有各的開關,互不影響。這是明確開關,伺服器不會自動偵測環境
matrix.admin-api-prefix 'admin' 後台路由前綴
matrix.admin-captcha-provider 'captcha-none' 後台登入要用哪個驗證碼 driver,對應 resources/cfg/{值}.php。出貨四個:captcha-none(預設,不要驗證碼——captcha 回 data: null、login 不再要求 token/code)、captcha-image(圖形)、captcha-turnstile(Cloudflare Turnstile)、captcha-recaptcha(Google reCAPTCHA)。預設是關的,因為前端的 config.captcha 也預設不畫任何驗證碼——兩邊不成對的話登入一律 422(driver 宣告自己要的憑證欄位,所以 fields 會指名缺的是 token 還是 code),所以要驗證碼就兩邊一起開。解不出 driver 一律 invalid-captcha-driver,不會靜默放行。這個鍵只管後台;前台要驗證碼是你自己呼叫 CaptchaService,見前台身分
matrix.admin-menus 'base' 要載入哪些選單 bundle,空白分隔,排前面的覆蓋排後面的
matrix.api-encryption false api 前綴要不要傳輸加密。出貨關著,因為這一組的呼叫方包含 Telegram 這種不可能配合的第三方
matrix.api-prefix 'api' 前台路由前綴
matrix.date-format 'Y-m-d' 日期顯示格式
matrix.datetime-format 'Y-m-d H:i:s' 日期時間顯示格式
matrix.directory-permission null 上傳與縮圖自動建立目錄時,對新建的每一層 chmod 的權限(例如 0777)。null = 不處理,權限由 umask 決定(通常 0755,只有建立者能寫)。command(例如 seeder 匯入檔案)與 web server 用不同帳號執行時,先建立當月目錄的一方會讓另一方寫不進去,要設這個值;已存在的目錄不會變更
matrix.drive-disk 'local' 雲端硬碟實體檔案的 disk,獨立於 file-*-disk。必須是 local driver(見下方限制)
matrix.file-private-disk 'local' 非公開檔案的 disk。必須是 local driver
matrix.file-public-disk 'public' 公開檔案的 disk。必須是 local driver
matrix.geolocation-provider 'ip2location-bin' IP 地理位置查詢要用哪個 driver,對應 resources/cfg/{值}.php
matrix.locales 'tw en' 允許的語系,對應 resources/i18n/{語系}/
matrix.member-model Member::class 會員 model,宿主可換成自己的
matrix.messaging 見範本 channel 註冊。每個 channel 都要有 model 與 queue。巢狀 key,宣告就整份取代
matrix.packages 'app base' 資源疊層順序
matrix.passkey-rp-id null Passkey Relying Party ID。null 時 fallback 用當次請求的主機名稱——若後台前端與此 API 不同源,務必明確設定,見已知限制與取捨。允許的 origin 由此值(加上 admin.passkey-allow-subdomains)推導,預設一律要求 https://{rp-id};此值出現在 admin.passkey-http-rp-ids 白名單裡才會額外放行 http://{rp-id}(本機開發用)
matrix.resource-cache-enabled false 是否快取資源 bundle defaults(檔案)與 override(DB)。預設不快取,每次都直接讀檔案/DB;設 true 才會讀寫 cache store
matrix.resource-cache-store null 資源 bundle defaults(檔案)與 override(DB)要快取到哪個 cache store,null = 用預設 store(cache.default)。清快取見 matrix:clear-resource-cache
matrix.public-cfg [] api/common/cfg 可匿名讀取的 cfg bundle 白名單(含資源後台的覆蓋值),不在名單內的一律回 null
matrix.public-i18n [] api/common/i18n 可匿名讀取的翻譯檔白名單,依 Matrix-Locale 回傳該語系
matrix.resource-cfg [] 資源後台開放編輯的 cfg bundle 白名單,空 = 全部不開放
matrix.resource-i18n [] 同上,一般翻譯
matrix.resource-i18n-menu [] 同上,選單標題
matrix.resource-i18n-model [] 同上,欄位標題
matrix.resource-i18n-options [] 同上,下拉選項
matrix.resource-i18n-template [] 同上,訊息樣板
matrix.thumbnail-quality 80 縮圖 webp 編碼品質(0-100)
matrix.thumbnail-sizes ['icon' => 64, 'thumb' => 256] 可用的縮圖 size 參數與對應寬度(px),?size= 帶不在此清單內的值一律回原始檔案
matrix.translation-provider 'google-translate' 內容翻譯要用哪個 driver,對應 resources/cfg/{值}.php。出貨兩個:google-translate(Google Cloud Translation,帶 format=html)、gemini-translate(Gemini generateContent,靠 prompt 要求保留標籤,見已知限制與取捨)
matrix.variants [] Definition::composite() 的接線:{group} => ['driver' => TypeResolver 類別, {type} => Variant 類別, …]。刻意放 config 不放 cfg —— 它指的是要實例化的類別,而 cfg bundle 一旦進了 matrix.resource-cfg 白名單就能從資源後台改
matrix.vendor-api-encryption true vendor 前綴要不要傳輸加密
matrix.vendor-api-prefix 'vendor' 廠商路由前綴
matrix.vendor-model Vendor::class 廠商 model

白名單只擋非 ROOT。ROOT 不受它限制。

cfg 設定鍵

可以在資源後台線上編輯,也可以在自己的 resources/cfg/{bundle}.php 覆蓋。

鍵 出貨值 說明
admin.captcha-ttl 300 驗證碼有效秒數
admin.login-throttle-max 5 每個窗口容許的登入失敗次數
admin.login-throttle-window 1 節流窗口(分鐘)
admin.mfa-challenge-ttl 300 MFA challenge 有效秒數
admin.mfa-trust-days 30 「信任此瀏覽器」cookie 的有效天數
admin.mfa-window 1 TOTP 驗證時間漂移容忍度(±N 個 30 秒區間)
admin.passkey-allow-subdomains false 是否允許子網域的 origin 通過驗證
admin.passkey-challenge-ttl 120 Passkey 註冊/登入 challenge 有效秒數
admin.passkey-http-rp-ids '' 逗號分隔的 RP ID 清單,清單內的值額外放行 http:// origin(本機開發用,本機以外不要設)。空字串 = 一律要求 https://。標記為 readonly,資源後台改不動——放寬 origin 是降低認證強度的操作,只能改檔案(resources/cfg/admin.php)
admin.passkey-timeout 60000 前端 ceremony 逾時毫秒數(供前端顯示,伺服器不強制)
admin.password-pattern '/^(?=.*\d)(?=.*[a-zA-Z]).{8,}$/' 自助改密碼、matrix:passwd 與使用者表單共用的密碼規則
admin.token-idle-minutes 30 後台 token 閒置多久失效
captcha-image.driver ImageDriver::class 圖形驗證碼。答案存 cache,所以需要 ext-gd(含 FreeType)與跨請求共用的 cache store
captcha-none.driver NoneDriver::class 不要驗證碼。generate() 回 null、verify() 一律通過
captcha-recaptcha.driver RecaptchaDriver::class Google reCAPTCHA
captcha-recaptcha.action 'login' 要比對的 action,與回應對不上就算失敗
captcha-recaptcha.endpoint 'https://www.google.com/recaptcha/api/siteverify' 驗證端點
captcha-recaptcha.hostnames '' 允許的 token 來源網域清單(空白/逗號/分號分隔),對應 siteverify 回應的 hostname。空字串 = 不檢查,見已知限制與取捨
captcha-recaptcha.secret '' 後端密鑰,出貨是空的,自己填。空字串不會放行——驗證一律失敗,登入擋下
captcha-recaptcha.threshold 0.5 分數門檻,低於就擋。回應沒有 score 時當成 0.0(擋下)
captcha-turnstile.driver TurnstileDriver::class Cloudflare Turnstile
captcha-turnstile.action 'login' 同 captcha-recaptcha.action
captcha-turnstile.endpoint 'https://challenges.cloudflare.com/turnstile/v0/siteverify' 驗證端點
captcha-turnstile.hostnames '' 同 captcha-recaptcha.hostnames
captcha-turnstile.secret '' 同 captcha-recaptcha.secret
encryption.grace-period 86400 輪替後舊金鑰還能解密多久(秒);matrix:rotate-encryption-key --grace 可以單次覆蓋
encryption.window 300 加密信封的 ts 容許誤差(秒),同時是同一個 epk 的去重保留時間(2 倍)
member.login-throttle-max 5 同上,前台會員
member.login-throttle-window 1 同上,前台會員
member.password-pattern '/^(?=.*\d)(?=.*[a-zA-Z]).{8,}$/' 同上,前台會員
member.token-idle-minutes 30 同上,前台會員
vendor.login-throttle-max 5 同上,廠商
vendor.login-throttle-window 1 同上,廠商
vendor.password-pattern '/^(?=.*\d)(?=.*[a-zA-Z]).{8,}$/' 同上,廠商
vendor.token-idle-minutes 30 同上,廠商
gmail.driver MailerMailDriver::class 這個供應商用哪個 driver 送,必填
gmail.host 'smtp.gmail.com' SMTP 主機
gmail.port 587 SMTP 連接埠
gmail.encryption 'tls' ssl 走 smtps,其餘走 smtp
gmail.username '' SMTP 帳號
gmail.password '' SMTP 密碼
gmail.from-address '' 寄件者位址,寫入 base_mail_log.sender 時快照
gmail.from-name '' 寄件者顯示名稱
gmail.interval 1 同一個供應商兩次送出之間的最短秒數,worker 用 sleep 實現
gmail.sandbox false 開啟後所有訊息改寄到 sandbox-recipient,主旨後面附上原收件者,副本與密件副本不寄
gmail.sandbox-recipient '' 沙箱收件者。sandbox 開著而這裡是空的就 invalid-message-receiver
mitake.driver MitakeSmsDriver::class 同 gmail.driver
mitake.endpoint 'https://smsapi.mitake.com.tw/' API 端點,空字串就 invalid-message-provider
mitake.username '' API 帳號
mitake.password '' API 密碼
mitake.accepted-status '0 1 2 4' 視為送出成功的 statuscode,空白分隔。不在名單內就 message-refused-by-provider
mitake.interval 1 同 gmail.interval
mitake.sandbox false 同 gmail.sandbox
mitake.sandbox-recipient '' 同 gmail.sandbox-recipient
webpush.driver WebPushDriver::class 同 gmail.driver
webpush.subject '' VAPID subject,mailto: 或網址
webpush.public-key '' VAPID 公鑰
webpush.private-key '' VAPID 私鑰
webpush.interval 1 同 gmail.interval
telegram.driver TelegramDriver::class 同 gmail.driver
telegram.bot-token '' Bot API token,呼叫 sendMessage/setWebhook 都用它
telegram.bot-username '' 組 link() 回傳的 deep-link(https://t.me/{bot-username}?start=...)用,不含 @
telegram.webhook-secret '' TelegramWebhookController 拿來比對 X-Telegram-Bot-Api-Secret-Token header,空字串會讓 webhook 對所有請求一律 403
telegram.interval 1 同 gmail.interval
telegram.sandbox false 同 gmail.sandbox,開啟後訊息一律送到 sandbox-recipient 那個 chat_id
telegram.sandbox-recipient '' 同 gmail.sandbox-recipient
file.max-size 0 上傳大小上限,0 = 不限制
file.mime-patterns '' 型別白名單(正則,空白 = 不檢查)
drive.deduplicate true 開啟後,雲端硬碟上傳內容雜湊相同的檔案會共用同一份實體檔案,不重複寫入
drive.trash-default-days 30 drive/trashed 預設只列出這幾天內的垃圾桶項目,可用 days/all 參數放寬,不影響 restore

gmail、mitake、webpush 與 telegram 是出貨的供應商 bundle。加一個供應商就是加一份 resources/cfg/{名稱}.php,鍵的形狀照上面四組。

主控台指令

指令 作用
matrix:clear-resource-cache 清掉所有已快取的 cfg/i18n/menu/style 資源 bundle defaults 與 DB override 快取(matrix.resource-cache-store)。部署後改了資源檔案卻沒生效,先跑這個
matrix:make-crud 傳入資料表名稱({table}),從已存在的資料表反向產生 Model、Declaration、Controller 草稿與 resources/i18n/{locale}/model/{table}.php(欄位清單、型別、unique 約束、translatable 欄位皆由 information_schema 內省;required 不從 schema 推斷)。不存在才寫入,--force 才覆寫既有檔案,--dry-run 只印出全部內容不寫入;路由(routes/*.php)、選單(resources/menu/*.php)與選單標題(resources/i18n/{locale}/menu/*.php)只印出建議片段供人工貼上,不會自動改寫這幾份共用陣列檔。只有 --parent 指定的那個關聯會被轉成 belongsTo() 並在清單頁享有 joined() 智慧轉換,其餘一般外鍵欄位清單頁仍顯示裸 ID;複合(多欄位)unique/FOREIGN KEY 約束不自動處理;只支援 public schema 的 PostgreSQL。欄位若在資料庫設有 COMMENT,該註解會直接作為應用程式預設語系(app()->getLocale())的欄位標題,其餘語系透過已設定的翻譯 provider 自動翻譯;沒有 COMMENT、翻譯 provider 未設定或翻譯失敗時,該語系退回 TODO: {欄位} 並列入警示清單
matrix:passwd 設定後台帳號密碼,建立管理員的唯一官方入口
matrix:prune-tokens 刪掉已經不能用來認證的 token,--limit 控制每批筆數(預設 1000)
matrix:rotate-encryption-key 產生新的 API 加密金鑰並把舊的標記為 expire_time = now + grace(--grace 秒數,預設 encryption.grace-period);同時刪除已經過完寬限期的舊金鑰。任何一組前綴的加密開關是開的,上線之前就必須先跑過一次——金鑰圈是空的時候,bootstrap 端點只會回 encryption-unavailable
matrix:sync-translatable 掃描所有套件、所有 Model 的 translatable 欄位,幫缺少目前設定語言的欄位補上實體欄位(皆為 nullable,不回填)
messages:dispatch 為每個有待送訊息的 channel 派送一個發送工作;任一 channel 設定壞掉就回非零 exit code
messages:telegram-webhook 呼叫 Telegram setWebhook API,把 {APP_URL}/{api-prefix}/telegram/webhook 連同 telegram.webhook-secret 註冊上去;一次性維運操作,環境變了(換網域、換 ngrok 網址)要重跑

錯誤代碼

代碼 意思
actor-already-assigned 身分已設定,不可重複指派
captcha-request-failed 驗證碼請求失敗
data-conflicted 資料已被修改
data-in-use 這筆資料仍被其他資料參照,無法刪除
data-not-found 查無資料
directory-create-failed 無法建立目錄
drive-anchor-immutable home 目錄與群組目錄不能被搬移或丟進垃圾桶
encryption-unavailable 金鑰圈沒有可用的金鑰(還沒跑過 matrix:rotate-encryption-key)
endpoint-not-found 端點不存在
file-too-large 檔案大小超過限制
file-write-failed 檔案寫入失敗
geolocation-database-not-found 找不到地理位置資料庫檔案
geolocation-request-failed 地理位置查詢請求失敗
image-decode-failed 無法解析圖片
import-column-unsupported 必填欄位無法匯入
import-failed 匯入資料有誤
invalid-arrange-order 上下架選擇與資料不符
invalid-captcha-driver 驗證碼服務設定錯誤
invalid-cascade-relation 連動關聯必須是 hasOne、hasMany 或其 morph 形式
invalid-column-condition 欄位條件語法錯誤
invalid-column-expression 欄位運算式語法錯誤
invalid-drive-file 不是有效的雲端硬碟檔案
invalid-drive-folder 不是有效的雲端硬碟資料夾
invalid-envelope 加密信封無法解讀:kid 不存在或已過期、GCM 驗證失敗、ts 超出容許誤差,或同一個信封被重送
invalid-filter-value 篩選值的格式不正確
invalid-geolocation-driver 地理位置服務設定錯誤
invalid-identity-model 身分 model 設定錯誤
invalid-identity-type 不支援此身分類型
invalid-ip-address IP 位址格式不正確
invalid-locale 語系不正確
invalid-message-channel 訊息管道設定錯誤
invalid-message-content 訊息內容不得為空
invalid-message-driver 訊息傳送器設定錯誤
invalid-message-provider 訊息供應商設定錯誤
invalid-message-receiver 收件對象不得為空
invalid-mime-type 不接受這種檔案類型
invalid-move-target 無法移動到該目的地
invalid-parent-relation 上層關聯必須是 belongsTo
invalid-preference-column 雲端硬碟呈現方式僅適用於後台使用者的偏好設定
invalid-preference-key 未宣告的偏好設定項目
invalid-resource-token 資源代碼格式錯誤
invalid-sort-order 排序內容與資料不符
invalid-staged-child 暫存子資源必須是已掛載、非獨立且有父資料的資源
invalid-token 登入憑證無效或已過期
invalid-translation-driver 翻譯服務設定錯誤
invalid-type-resolver 複合欄位的型別判斷器設定錯誤
menu-depth-exceeded 此選單項目底下不能再新增項目
menu-locked 此選單項目由系統管理,不可刪除或修改
message-provider-has-no-driver 訊息供應商未設定傳送器
message-refused-by-provider 訊息被供應商拒絕
message-template-not-found 查無訊息樣板
mfa-already-enabled 雙因子驗證已經啟用
name-already-exists 這個名稱在此位置已經存在
nested-composite-not-supported 複合欄位不支援巢狀
page-content-locked 預設內容區塊只能由系統建立,不可複製或刪除
permission-denied 權限不足
push-delivery-failed 推播通知無法送達任何訂閱
push-subscription-not-found 這個收件者沒有可用的推播訂閱
request-failed 請求無法處理
server-error 系統發生錯誤
staged-key-conflict 暫存子資源的 key 不可與父欄位同名
too-many-requests 請求過於頻繁,請稍後再試
translation-request-failed 翻譯請求失敗
undeclared-action Action 未宣告
undeclared-model Model 未宣告欄位
unknown-message-channel 訊息管道未註冊
unknown-package 套件未註冊
unsupported-disk-driver disk 的 driver 不是 local
validation-failed 輸入資料有誤

資料表

資料表 內容
base_auth_token 各身分的登入 token
base_city 縣市
base_city_area 行政區
base_drive_node 雲端硬碟節點(資料夾與檔案共用同一張表);root/home/群組三個固定區域,軟刪除、無永久刪除
base_encryption_key API 傳輸加密的金鑰圈(EC P-256;私鑰以 APP_KEY 加密存放),expire_time 是輪替後的寬限期終點
base_file 上傳檔案(含去重雜湊與媒體資訊)
base_group 後台群組
base_mail_log 郵件佇列與寄送結果
base_manipulation_log 稽核軌跡(誰改了哪一列的哪個欄位;model 不能修改或刪除既有列)
base_member 前台會員
base_member_log 會員行為紀錄
base_menu 可線上維護的選單資料
base_page 可線上維護的前台頁面;path 是前台查找用的唯一網址,seo_title 每個語系都必填,data 的欄位形狀由消費端在 config('matrix.variants.page-data') 接線決定
base_block 頁面底下的區塊,page_id 掛在 base_page;type 決定 data 的欄位形狀,由消費端在 config('matrix.variants.block-data') 接線定義。type 在 insert 時決定、之後不可改
base_block_item 區塊底下的子項目,block_id 掛在 base_block;型別跟著父區塊走,所以自己沒有 type 欄位
base_passkey_credential 後台帳號(User)的 Passkey 憑證(credential ID、COSE 公鑰、sign count 等)
base_preference 各身分(user / member / vendor)各自一筆的個人化偏好,內容由前端決定
base_push_log 推播佇列與送達結果
base_push_subscription 會員的 Web Push 訂閱(endpoint / p256dh / auth)
base_resource_override 資源後台線上編輯的覆蓋值
base_sms_log 簡訊佇列與寄送結果
base_telegram_log Telegram 佇列與送達結果
base_telegram_subscription 後台使用者(User)的 Telegram 綁定(user_id / chat_id / username)
base_user 後台帳號(含選填的姓名 name、信箱 mail、電話 phone)
base_user_log 後台帳號的登入 / 改密碼紀錄
base_vendor 廠商
base_vendor_log 廠商行為紀錄
base_operator 檢視表,把 user / member / vendor 併成一份「操作者」清單,給宿主查 creator_id 用。唯讀,不要對它寫入

給前端

請求形狀

清單(POST admin/widget):

{
  "filters": {
    "title": { "op": "contains", "value": "abc" },
    "status": { "op": "in", "value": [1, 2] },
    "create_time": { "op": "between", "from": "2026-01-01 00:00:00", "to": "2026-01-31 23:59:59" }
  },
  "sort": [{ "name": "create_time", "direction": "desc" }],
  "page": 1,
  "size": 10
}
  • 每個欄位可以用哪些 op,由清單回應的 columns[].op 告訴你。送不被允許的欄位或運算子會靜默忽略,不會報錯。

  • between 用 from / to(可以只給一邊),其餘用 value。

  • date / datetime 欄位的值必須完全符合 columns[].format 給的格式,一個字元都不能差:date 只收 matrix.date-format(出貨 Y-m-d),datetime 只收 matrix.datetime-format(出貨 Y-m-d H:i:s),兩者不互通。不符回 422 invalid-filter-value。所以查一整天要自己補時間:from 是 00:00:00、to 是 23:59:59——只送 2026-01-31 會被擋下。

  • page 或 size 給 0 以下 = 不分頁,一次全回。

  • 可上下架的清單(metadata 同時有 enable / disable)另收 filters.schedule = { "op": "eq", "value": "<id>" }。schedule 在這類 model 上是保留名稱,由框架處理,不交給一般欄位篩選。五個 id 有包含關係、不互斥:

    id 意義
    enabled 上架中:上架時間不晚於現在,且下架時間為空或晚於現在
    disabling 即將下架:上架中且下架時間晚於現在
    disabled 未上架:非上架中,是 enabled 的精確補集(含上架時間為空)
    enabling 即將上架:上架時間晚於現在
    expired 已下架:下架時間不晚於現在

    value 為 null 或空字串 = 不篩選;op 不是 eq 靜默忽略;未知 id 回 422 invalid-filter-value。上架時間在未來、下架時間已過的列同時命中 enabling 與 expired。匯出同樣適用。

詳情 / 編輯頁(POST admin/widget/{id})不需要 body。

新增與更新(admin/widget/insert、admin/widget/{id}/update):欄位平鋪在最上層。

更新是全量覆寫,不是局部更新。 每一個可寫欄位都會被加上 present 驗證規則 —— 不送就是 422,送 null 就是把它清成 null。前端要送完整的表單,不能只送改過的欄位。

刪除:{"id": [1, 2, 3]},任一筆不存在整批失敗(data-not-found)。

匯出(admin/widget/export):與清單相同的 filters / sort,可另帶 {"id": [1, 2, 3]} 只匯出這些列 —— 與篩選取交集、照 sort 排序,不存在或範圍外的 id 直接略過(不回 data-not-found);不帶或空陣列 = 全部。

匯入範本(admin/widget/import/template):回 { title, columns, rows: [] },與匯出同形;columns[] 另帶 required 與 options(該欄可反查的選項標題清單,沒有選項或有 lookup() 的欄位為 null),前端據此產範本、做下拉選單、把表頭對回欄位名。

匯入(admin/widget/import):{"rows": [{"row": 2, "values": {"title": "…", "name__tw": "…"}}]}。row 是工作表列號(表頭是第 1 列),只用來回報錯誤;values 的鍵是欄位名、值是字串,不在可匯入欄位裡的鍵忽略。成功回 {"count": 12};任一列有錯回 422 import-failed,整批退回:

{ "success": false, "code": 422, "error": "import-failed", "message": "…",
  "rows": [ { "row": 3, "fields": { "name__en": ["required"] } }, { "row": 7, "fields": { "*": ["query-failed"] } } ] }

代碼沿用 validation.<code>;* 是對不到欄位的資料庫錯誤(違反 NOT NULL、唯一索引等),整列標示。rows 缺少、空陣列或形狀不對 → 一般的 validation-failed。

拖曳排序儲存(admin/widget/sort/save):{"order": [3, 1, 2]},必須是完整集合,少一筆就是 invalid-sort-order。

回應形狀

清單回應含 columns(欄位描述)、rows、pagination、actions、title、麵包屑等。columns[] 每一項的三個維度要分開讀:

  • type 是資料型別(text / integer / float / boolean / date / datetime / json)—— 決定驗證與比較。
  • presentation 是呈現方式(plain / hidden / select / multi-select / password / count / switch)—— 決定畫面怎麼畫。宣告時給的字串若不是這七個,會原樣送給前端,那是自訂呈現方式的出口。
  • writable 是這一欄送回來會不會被寫入。

一個「有選項的整數欄位」是 type: integer + presentation: select,三個維度都完整。

type 是 date 或 datetime 的欄位另外帶一個 format(例如 YYYY-MM-DD、YYYY-MM-DD HH:mm:ss,即 matrix.date-format / matrix.datetime-format 轉成前端慣用的日期格式代號),rows/data 裡該欄位的實際字串就是照這個格式輸出;其餘型別 format 是 null。

清單回應另有 filters[]:只用於篩選、不是資料欄的欄位描述,形狀同 columns[]。前端為每一項畫一個單選下拉(前面補「全部」= 不送),送出格式為 filters.{name} = { "op": {描述的 op}, "value": ... };不要畫成表格欄或併進搜尋欄位。目前只有可上下架清單會有一項 schedule,其餘清單是空陣列。

不可寫有三種原因:readonly 宣告、locked 宣告、以及跨關聯或聚合欄位(group.title、count(orders))。 前兩種在 columns[] 上另有 readonly / locked 鍵看得出來,但判斷能不能編輯時前端要看 writable,不要看 readonly —— 否則第二種會畫出一顆改了完全沒效果的輸入框,而使用者會看到「已儲存」。virtual(+ 前綴)不影響 writable:虛擬欄位不落庫,但照樣可以收值交給 guards / 覆寫的 service 處理。

writable: false 的欄位不在 present 驗證的範圍內,送不送都可以;writable: true 的每一個都必須出現在 body 裡,那與上面「更新是全量覆寫」是同一件事的兩面。

鎖定欄位是這條規則唯一的例外:它的 writable 是 false,但它必須出現在 body 裡。把輸入框停用即可,值照送 —— 值本來就是伺服器在 new 回應的 data 裡給你的。清單的「新增」要不要先跳型別選擇器,看的是 locked: true 且帶選項的欄位,不是 writable: false —— 唯讀欄位同樣不可寫,但它的值不是在新增前決定的。

action 的 url 不含前綴。 回應給的是 widget/{id}/update 這種相對路徑,前端要自己接上 admin/。

語系

送 Matrix-Locale: en header。值必須在 matrix.locales 裡,否則退回應用程式的預設語系。

api/common/page

前台頁面樹。匿名,body 帶 path(base_page.path,唯一)。查不到、或頁面不在 enable_time / disable_time 的區間內,一律回 404 data-not-found。

{
  "id": 10000012,
  "path": "about-us",
  "title": "關於我們",
  "seo_title": "…",
  "seo_description": "…",
  "og_image": [{ "path": "@cms/og.jpg", "name": "og.jpg", "mime_type": "image/jpeg", "size": 92160, "width": 1200, "height": 630, "seconds": null }],
  "data": {},
  "children": [
    { "id": 10000031, "type": "none", "title": "首圖", "data": {}, "children": [
      { "id": 10000071, "title": "第一張", "data": {}, "children": [] }
    ] }
  ]
}
  • 樹固定兩層:頁面 → 區塊 → 子項目。子項目的 children 恆為 [],留著是為了讓遞迴渲染不用分兩種節點型別。
  • title / seo_title / seo_description / og_image 依 Matrix-Locale 挑語系,沒有 fallback:該語系沒填就是 null(og_image 是 [])。
  • 兩層都各自走排程,排序依 ranking。區塊未啟用只是該區塊不出現,不會讓整頁消失;子項目同理。
  • type 是前端挑版型的唯一依據。 套件本身不出貨任何型別;config('matrix.variants.block-data') 沒接上該型別時,data 原樣輸出儲存值,不做語系攤平。
  • 三層的 data 空的時候是 {} 不是 [],不用分兩種型別處理。api/common/menu 的 data 同理。og_image 相反,它是清單,空的時候是 []。
  • data 裡的多語系子欄位已經壓成單一值。 {"caption": {"tw": …, "en": …}} 出來會是 {"caption": "…"},跟 title 一樣依 Matrix-Locale 挑好。非多語系的子欄位原樣不動。api/common/menu 的 data 也是同一個規則。
  • 圖檔欄位給的是路徑不是網址,自己組 GET {api-prefix}/files/{path},縮圖加 ?size=thumb。

驗證碼

前後端必須成對。 後端的 matrix.admin-captcha-provider 決定驗哪一種憑證,前端的 config.captcha 決定畫哪一種元件——兩邊對不上的話登入一律 422。每個 driver 會宣告自己要哪些憑證欄位(captcha-image 要 token + code,Turnstile / reCAPTCHA 只要 token,captcha-none 兩個都不要),所以前端少送的時候錯誤是 validation-failed 並在 fields 裡指名缺的是 token 還是 code,而不是含糊的驗證碼錯誤。

後端 matrix.admin-captcha-provider 前端 initConfig({ captcha }) 還要準備什麼
captcha-none(出貨預設) MxCaptchaNone(出貨預設) 無
captcha-image MxCaptchaImage ext-gd(含 FreeType)、跨請求共用的 cache store
captcha-turnstile MxCaptchaTurnstile site key + cfg('captcha-turnstile.secret')
captcha-recaptcha MxCaptchaRecaptcha site key + cfg('captcha-recaptcha.secret')

這個鍵只管後台登入。前台(member / vendor)的登入端點本來就要你自己做,要驗證碼就自己呼叫 app(CaptchaService::class)->driver('captcha-turnstile')——bundle 名必填,套件沒有替前台準備設定鍵,所以前後台要不要用同一個 driver 完全由你決定。

前端那半邊在宿主應用的 src/router/index.js:

initConfig({
    captcha: MxCaptchaTurnstile,
    captchaSiteKey: "0x4AAAAAAA..."
});

site key 是公開值,本來就會出現在前端 bundle 裡;secret 相反,只放後端。兩個 cfg bundle 的 secret 出貨都是空字串,自己填——空著不會放行,而是每次驗證都失敗、登入一律擋下。不想改檔就用 base_resource_override 存(見驗證碼服務掛掉時怎麼進後台),那張表同樣吃得下 secret。

⚠️ action 兩邊都寫死成 login。 前端的 turnstile.js / recaptcha.js 裡是常數、不吃設定,後端 cfg('captcha-{turnstile,recaptcha}.action') 的出貨值也是 login。只改後端那一個會讓每次驗證都失敗——要改就兩邊一起改,不然別動。

Cloudflare Turnstile

  1. Cloudflare 儀表板 → Turnstile → 新增 widget,Widget Mode 選 Managed。
  2. 把要跑後台的網域加進 widget 的 hostname 清單(本機開發另外加 localhost)。
  3. Site Key 填進前端的 captchaSiteKey,Secret Key 填進 cfg('captcha-turnstile.secret')。

前端 render 時帶 appearance: "interaction-only",所以多數訪客什麼都不會看到,只有被判定可疑的才跳挑戰。這個外觀設定只對 Managed 與 Non-Interactive 生效——widget 建成 Invisible 的話它不起作用。

Google reCAPTCHA

前端載的是 enterprise.js、呼叫 grecaptcha.enterprise.execute(),所以要建的是 reCAPTCHA Enterprise 的 score-based key(傳統 v2 那種勾選框已經無法申請新金鑰)。

  1. Google Cloud Console → Fraud Defense(reCAPTCHA 現在的所在)→ Keys → 新增 website key,類型選 Score-based。
  2. 網域加進該 key 的 domain 清單。
  3. Key ID 就是 site key,填進前端的 captchaSiteKey。
  4. Secret 要另外拿:Key details → Integration 分頁 → Use Legacy Key,對話框裡那把才是 cfg('captcha-recaptcha.secret') 要的值。

⚠️ 第 4 步是最容易卡住的地方。Enterprise 的 key 詳情頁預設不顯示 secret key,因為 Google 假設你會用 CreateAssessment API(要 service account)。這個 driver 走的是舊的 siteverify 端點,吃的是那把 legacy secret——每一把 Enterprise key 都有,只是藏在 Use Legacy Key 後面。

分數門檻是 cfg('captcha-recaptcha.threshold'),出貨 0.5。調它之前先看已知限制與取捨裡關於「分數不夠的真人沒有自證管道」那條。

傳輸加密

三組前綴各有各的開關,出貨值是 admin 開、vendor 開、api 關。開關關著的那一組一切照舊,以下完全不存在。開著的那一組,該前綴底下所有 POST 端點(除了宣告 #[Action(encrypted: false)] 的 action)的 request 與 response body 都是加密信封,明文請求一律回 invalid-envelope。

後台前端只打 admin,所以下面一律以它為例。

每開一個分頁做一次:POST encryption-key(明文,不在任何前綴底下)拿 {"kid", "public_key"},public_key 是 base64 的 SPKI DER,用 crypto.subtle.importKey('spki', ..., {name: 'ECDH', namedCurve: 'P-256'}, false, []) 匯入。

每次呼叫做一次:產生一組用完即丟的 P-256 金鑰對 → 跟伺服器公鑰做 ECDH → HKDF-SHA256 衍生出這次專用的 AES-256-GCM 金鑰,參數是 salt 空、info 為 UTF-8 的 matrix-api-v1、長度 256 bit。

{ "kid": "...", "epk": "<base64 SPKI>", "ts": 1757000000, "iv": "<base64 12 bytes>", "ct": "<base64 密文+tag>" }
  • ts 是秒為單位的 Unix 時間戳,與伺服器時間差超過 cfg('encryption.window') 就是 invalid-envelope。
  • epk 是一次性公鑰,同時當作防重放的 nonce:同一個 epk 只能用一次,重送第二次是 invalid-envelope。
  • AAD 是 request|{METHOD} {path}|{ts} 的 UTF-8 位元組。path 是完整的 URL 路徑去掉開頭斜線(即瀏覽器的 new URL(...).pathname,應用程式裝在子目錄時包含子目錄),例如 request|POST admin/user/insert|1757000000。少了它,同一份密文可以被原封不動搬去別支端點。
  • ct 是 crypto.subtle.encrypt 的原始輸出(密文後面接 16 bytes 的 GCM tag),不用自己拆。

回應是 {"iv", "ct"},用同一把衍生金鑰、新的 IV、AAD 換成 response|{METHOD} {path}|{ts}。解開之後才是平常那個 {"success": ...} 信封,錯誤信封也一樣被加密。

唯一的例外是解密失敗:中介層自己擋下來的錯誤沒有金鑰可以加密,會以明文回 {"success": false, "code": 400, "error": "invalid-envelope", "message": "..."}。前端在解密前必須先認這個形狀,不要無條件把回應丟進 crypto.subtle.decrypt。

沿用套件的 lint

選用。 前提是你的 app 也照套件那套風格公約寫:<?php //> 開頭、class 主體前後空一行、成員字母序、禁 ??、禁 saveQuietly 一類繞過 model 事件的寫法。不打算全套照用就不要接 —— 這些檢查沒有開關。(違規訊息會引用 CLAUDE.md,那是套件自己的風格公約文件,沒隨套件出貨,規則摘要見本節末的表格。)

套件的 require-dev 不會遞移到你的專案,工具要自己裝:

composer require --dev friendsofphp/php-cs-fixer larastan/larastan

1. composer.json

"scripts": {
    "format": "php-cs-fixer fix",
    "lint": [
        "php-cs-fixer fix --dry-run --diff",
        "@php vendor/matrix-platform/laravel-base/bin/style-check.php"
    ],
    "stan": "phpstan analyse --memory-limit=1G"
}

style-check.php 不傳參數時檢查當前工作目錄,而 composer script 的 cwd 就是 composer.json 所在的目錄,所以不用給路徑。要檢查別的目錄就傳第一個參數:@php vendor/.../bin/style-check.php packages/foo。

2. .php-cs-fixer.php

finder 指你自己的目錄,rules 引用套件那一份:

<?php //>

$finder = PhpCsFixer\Finder::create()
    ->in([__DIR__ . '/app', __DIR__ . '/config', __DIR__ . '/database', __DIR__ . '/resources', __DIR__ . '/routes', __DIR__ . '/tests'])
    ->name('*.php')
    ->notPath('#(^|/)menu/[^/]+\.php$#');

return (new PhpCsFixer\Config())
    ->setFinder($finder)
    ->setIndent('    ')
    ->setLineEnding("\n")
    ->setRiskyAllowed(false)
    ->setRules(require __DIR__ . '/vendor/matrix-platform/laravel-base/.php-cs-fixer.rules.php');

notPath 那行不要省。選單 bundle(menu/*.php,含 resources/i18n/*/menu/*.php)的縮排跟著選單層次走,array_indentation 會把它拉平。

3. phpstan.neon

這裡沒有共用檔:套件的設定只有 level: 8 一個參數,抄一行比多一層 include 划算。

includes:
    - vendor/larastan/larastan/extension.neon

parameters:
    level: 8
    paths:
        - app
        - tests
    tmpDir: build/phpstan

style-check 檢查什麼

腳本掃 app、bin、config、database、resources、routes、src、tests,不存在的目錄跳過,所以套件與宿主共用同一份預設。規則按路徑前綴分派:

檢查 生效範圍
<?php //> 開頭、class 主體前後空行、fn 後空一格、禁 ?? 與 ??= 全部
多行陣列的尾逗號 config/、resources/ 要加,其餘不加
方法鏈超過兩個 -> 要逐行展開 app/、src/、tests/
類別成員字母序 除 tests/ 之外全部
禁用繞過 model 事件的寫法 除 bin/、config/、resources/ 之外全部

程式碼放在這幾個目錄之外(例如模組化的 modules/)就完全不會被掃到,前綴對不上的部分也會靜默跳過對應檢查 —— 沒有警告。

已知限制與取捨

每一條都是刻意的決定或已知的缺陷,不是待辦清單。放在這裡是因為它們會影響你的部署決定。

安全

事實 你要做什麼
admin/i18n/get 是匿名端點,任何人可以讀走任何一份翻譯檔 —— 包含 template/*(郵件與簡訊樣板全文)。登入畫面需要它,所以不能關 不要在 i18n 資源裡放非公開內容
驗證碼端點匿名且沒有節流 需要的話自己加
驗證碼的 site key 是公開的,不設 hostnames 就擋不住別人拿它在自己的網站養 token —— 攻擊者把你的 site key 放進自己的頁面,訪客產生的 token 有 success、有正確的 action(那是他自己設的)、分數還是真人的高分,拿來打你的登入端點會完全穿透驗證碼這一關 設 captcha-recaptcha.hostnames / captcha-turnstile.hostnames 為後台登入頁的網域(可多個)。出貨是空的 = 不檢查,因為預設開啟會讓既有部署升級後全部登不進去,而症狀只會是「驗證碼錯誤」。這件事在本套件特別要緊——見下面那條,登入節流擋不住拿一組密碼掃一堆帳號,驗證碼是應用層唯一能讓 spraying 變貴的東西
captcha-recaptcha 是 Enterprise 分數式的,低於 captcha-recaptcha.threshold 的真人沒有任何自證管道 —— 它不跳挑戰,分數不夠就是進不去(傳統的 v2 隱形式已無法申請新金鑰) 用這顆就要求管理員都註冊 passkey(passkey 登入完全不經過驗證碼),否則唯一的出路是下方的 cfg override。要「可疑時才要求互動、互動完就過」請改用 captcha-turnstile
第三方驗證碼 driver(captcha-turnstile / captcha-recaptcha)是 fail-closed 的 —— 對方服務打不通時回 captcha-request-failed,登入一律擋下。而且每一次被擋都吃掉一格登入節流(afterCallback 只計失敗),五次之後同一組 IP+帳號連 too-many-requests 都會拿到 接受它,但事先知道逃生門怎麼走:見下方〈驗證碼服務掛掉時怎麼進後台〉。不要改成「打不通就放行」,那等於給攻擊者一個把驗證碼關掉的開關
api/common/city、menu、page 匿名且沒有節流,base_menu.data 與 base_page.data 的內容會原樣出現在回應裡 不要在這些 data 欄位放非公開資料。api/common/page 另外會把區塊引用的雲端硬碟檔案路徑吐出來,而 api/files/{path} 對 drive 檔案匿名可取、不看 privilege —— 區塊放什麼圖就等於公開發佈什麼圖
api/common/cfg、api/common/i18n 匿名且沒有節流,matrix.public-cfg、matrix.public-i18n 列出的 bundle 會原樣出現在回應裡(cfg 的 secret 欄位也不例外) 不要把含非公開內容或憑證的 bundle 列進這兩份白名單
登入節流的鍵是「IP + 帳號」 —— 同一個 IP 換帳號就換一份配額,擋不住拿一組密碼掃一堆帳號 要擋就在應用層之外做(WAF / 反向代理)
Passkey 登入端點沒有帳號欄位,節流退化成近似純 IP 比密碼登入更粗放的取捨,若濫用明顯可考慮改用 IP + credential_id 前綴 當節流鍵
matrix.passkey-rp-id 的 fallback 是當次請求的主機名稱,只在後台前端與此 API 同源時才正確;RP ID 一旦設錯或事後變更,所有已註冊 passkey 會全部永久失效,無遷移路徑(WebAuthn 規格的密碼學綁定特性) 若前後端分離部署在不同網域,啟用 passkey 前務必明確設定 matrix.passkey-rp-id,不要依賴 fallback
傳輸加密的三個開關(matrix.{admin-api,api,vendor-api}-encryption)都是明確開關,沒有自動偵測;crypto.subtle 只在 secure context(HTTPS 或 localhost / 127.0.0.1)存在,用區網 IP 走純 HTTP 的開發環境打不開它 那種環境把開關關掉,並且清楚知道那台機器沒有應用層加密。不要讓前端「偵測不到就自動退回明文」——那等於給攻擊者一個降級開關
防重放的去重表存在 Cache,一台機器記下的 epk 只有那台知道 多機部署時 CACHE_STORE 必須是跨機共享的(Redis),用 file / array 等單機快取的話,同一份密文換一台機器就能重送一次
加密只保護 body,不改變 token 的傳法(Authorization header / matrix-user cookie 還是原樣),也不取代 HTTPS 它防的是「TLS 在反向代理就終止、之後那段是明文」這個威脅,不是 HTTPS 的替代品
私鑰以 APP_KEY 加密後存在 base_encryption_key 資料庫與 APP_KEY 同時外洩,等於過去 encryption.grace-period 之內錄下來的流量都能解開。輪替頻率照你的合規要求定
base_auth_token.token 是明文 資料庫外洩等於所有人的登入狀態外洩,備份與存取控制要照這個等級處理
cookie 的 secure 跟隨 config('session.secure') 生產環境務必設成 true,否則 token 會在明文連線上傳
上傳不檢查型別(cfg('file.mime-patterns') 出貨空白 = 全部放行) 要限制就設 mime-patterns(正則,空白分隔,不能含逗號或分號 —— 那是分隔字元)
上傳不沿用使用者送來的副檔名 磁碟上是 年月/32 碼隨機名;只有伺服器偵測到的類型在 FileStorage::EXTENSIONS 白名單內(JPG / PNG / GIF / WebP / AVIF / SVG)才加上對應的副檔名,讓公開 disk 由 web server 直接提供時帶得出正確的 Content-Type(SVG 沒有 image/svg+xml 瀏覽器不會當圖片畫);副檔名看內容不看檔名,所以改名成 .jpg 的程式不會拿到可執行的副檔名,名單外的檔案一律不帶副檔名。下載的 Content-Type 取自 base_file.mime_type,檔名取自 base_file.name(原始檔名,含副檔名),使用者端無感
公開 disk 上的 SVG 由 web server 直接提供 SVG 可以內嵌程式碼:以 <img> 載入不會執行,但直接開啟它的網址會在那個網域上執行。正式環境建議讓 web server 對 .svg 加上 Content-Security-Policy: sandbox(例如 nginx location ~* \.svg$ { add_header Content-Security-Policy "sandbox"; }),或不開放上傳 SVG(file.mime-patterns)
上傳不限制大小(cfg('file.max-size') 出貨 0)。真正的上限是 PHP 的 upload_max_filesize / post_max_size 兩層都要調。只調 cfg 沒用,只調 ini 的話使用者會拿到 422 而不是 file-too-large
檔案的 privilege 決定存哪個 disk(0 公開 / 1 私有)。下載一律走 admin/file/download,要登入 公開 disk 若做了 storage:link,那些檔案就有公開 URL —— 這是 disk 設定的結果,不是套件的存取控制。另外「要登入」只是登入 —— admin/file/* 掛的是 user-api,沒有 permission-api,所以選單權限完全為空的管理員也能下載 / 改名任何 path 的檔案。要更細的檔案授權,自己在該路由前加 middleware
前台沒有上傳端點 前台要上傳就自己呼叫 FileService::upload(),並自己決定權限與限制
seeder 要預建圖片時用 FileService::import($path) 把專案內的檔案匯入 base_file(預設公開),走 upload() 的去重所以重跑 seeder 不會重複建檔;不套用 file.max-size / file.mime-patterns;以暫存複本上傳,不動來源檔。存進圖片/檔案欄位(DriveImage / DriveFile)的值用 FileService::entry($file) 產生,形狀與從雲端硬碟選檔後存下的相同
雲端硬碟(drive/*)上傳不檢查型別或大小,沒有等同 file.max-size / file.mime-patterns 的設定 要限制就自己在 DriveService::upload() 前面加檢查
drive/* 沒有選單節點,只掛 user-api(比照 admin/auth/passwd、admin/file/*)——任何登入的後台 User 都能呼叫,不需要選單授權 節點層級的 owner/群組存取完全交給 DrivePermissionService 這一層把關,不是靠選單權限
manipulation-log/query、schedule/toggle 同樣沒有專屬選單節點、只掛 user-api,但跟上面兩列不同——這兩支端點呼叫時會依請求帶的 prefix 找出目標資料實際所屬的既有選單節點,動態呼叫 AdminPermission::permits() 檢查 query/update 權限,行為上等同「借用」該資料原本的選單授權,不是完全不設防 prefix 必須是某個已掛載 ActionRoutes::mount() 的 CRUD 資源的路由前綴(如 group);對應不到就回 unsupported-model,對應得到但沒權限一律 403。manipulation-log/query 另外會呼叫該資源 controller 的 historyVisible(int $id),回 false 就回 404 data-not-found:預設一律 true,UserController 依帳號階層判斷(Admin 查不到 Root、Regular 只查得到 Regular)。判斷只拿得到 id(硬刪除的資料也要查得到歷史),所以 controller 有資料範圍限制時要自己覆寫;稽核紀錄是依資料表存的,多個 model 共用同一張表時,也要在這裡排除不屬於自己的 id
manipulation-log/query 的回應帶一份 options(欄位名 → 選項清單,與 CRUD columns[].options 同構),而且含已被軟刪除的關聯,那些選項的 deleted 是 true 歷史紀錄裡存的是 id,要翻得出名字就不能像表單那樣把軟刪除的關聯濾掉。CRUD 自己的 new / {id} / 清單 / 匯出維持原樣(只給沒刪除的),不受影響
drive/{id}/delete 只軟刪除目標本身,不遞迴子項目;但被刪節點底下沒被動到的子孫會變成整體不可操作——DrivePermissionService::allowed() 往上爬錨點時遇到已軟刪除的祖先就直接判定沒有權限,User::ROOT 也不例外 這是刻意的設計:不用遞迴刪除/還原,單純靠「祖先鏈斷在已軟刪除的節點」讓整個子樹自然變成不可操作;restore() 也只還原目標本身,把祖先救回來,子孫的可操作性就自動恢復。但「看不看得到」是另一回事——drive/trashed 跟 drive/{id}/path 用的是 visible(),爬的時候會穿過軟刪除的祖先繼續找,所以子孫依然會出現在垃圾桶列表、路徑依然查得到,只是在祖先還原之前 restore() 會報 permission-denied
欄位 DSL 是開發者輸入,識別字會被插值進 SQL 絕對不要把使用者輸入拼進 $lists / $updates
權限白名單只覆蓋 CRUD 的寫入路徑。replicate()、setRawAttributes()、query builder 的 update() 都繞得過去 白名單防的是請求輸入,不是程式碼
訊息樣板的變數會原樣進入 HTML,不逸出 把使用者輸入當變數傳進去之前自己逸出。開放樣板編輯 = 把那個人當成信任的 HTML 作者
admin/translation 送出去翻譯之前會保護佔位符,但只保護獨立出現的 {name} / {{name}}——HTML 屬性值裡的(<a href="?token={code}">)原樣送給翻譯服務,翻回來可能已經被改掉 要翻的字串裡別把佔位符寫進屬性值。保護的作法是先換成一個標記元素、翻完再換回來;標記被翻譯服務改寫到認不出來時,整次翻譯回 available: false(不會回傳夾著標記殘骸的譯文),應用程式日誌記 translation.failed / placeholder-lost。gemini-translate 這類 LLM driver 踩到這條的機率比 google-translate 高——保留標記元素只能靠 prompt 要求,沒有 format=html 那種協定層保證。prompt 本身在 cfg('gemini-translate.prompt'),標了 readonly,只能改檔案不能從資源後台編;自訂時務必留著「原樣保留 HTML 標籤」那條規則與 :text 佔位符 —— 漏掉 :text 等於原文沒送出去,一律 invalid-translation-driver

驗證碼服務掛掉時怎麼進後台

config/matrix.php 出貨模板裡沒有任何 env(),所以照抄的話改 provider 等於改檔加重新部署 —— 凌晨三點那不是逃生門。資源後台的 cfg 編輯介面也不能用,因為它要先登入,而登入正是被擋住的那件事。

第一道逃生門其實很便宜:那個檔案是你自己應用的(套件只 mergeConfigFrom 預設值,見安裝),所以你大可自己寫成 'admin-captcha-provider' => env('MATRIX_ADMIN_CAPTCHA_PROVIDER', 'captcha-none'),事後只要改 .env。代價是若有 config:cache 就得再跑一次 php artisan config:cache —— 零改碼,但不是零操作。

沒事先鋪這條線、而且現在不能部署的話,才輪到下面這個:

實際可用的是 DB override(Resources::overrides() 會把 base_resource_override 疊在 cfg 預設值上):

INSERT INTO base_resource_override (bundle, data, create_time)
VALUES ('cfg/captcha-turnstile', '{"driver":"MatrixPlatform\\Captcha\\NoneDriver"}', NOW());

⚠️ 那是兩個反斜線,不是四個,而且 create_time 不能省。 PostgreSQL 的 standard_conforming_strings 預設是 on,字串字面量裡的反斜線不再被跳脫,所以 JSON 的 \\ 剛好解成一個反斜線;寫成四個會存進 MatrixPlatform\\Captcha\\NoneDriver,那不是合法的類別名稱,resolve_driver() 會改丟 invalid-captcha-driver —— 逃生門反而把門鎖得更死。create_time 是 NOT NULL 且沒有預設值,漏掉會直接被資料庫擋下(這個至少是大聲失敗)。

php artisan matrix:clear-resource-cache

驗證碼立刻放行,零部署、零改檔。清快取那一步不能省 —— overrides() 是 rememberForever。

同一張表也可以只調參數而不整個關掉,例如把 reCAPTCHA 的分數門檻放寬:{"threshold":0.1}。

⚠️ 服務恢復之後,先前被擋掉的失敗仍然佔著登入節流的配額,可能還要等一個 admin.login-throttle-window 或自己清掉 rate limiter。

⚠️ 新增或改動 cfg bundle 的檔案之後也要跑同一個指令 —— Resources::merge() 把「這個 bundle 不存在」也一起 forever 快取了,不清的話會一直拿到 invalid-captcha-driver。

規模與效能

事實 影響
匯出會把整張表(套完篩選後)載進記憶體 沒有任何上限。大表要匯出請自己做背景任務
匯入整批在一個請求、一個交易內處理 沒有筆數上限,實際上限由 post_max_size 與請求逾時決定
_id 欄位的下拉選項成本與被參照資料表的列數成正比 被參照的表上千列時,那份選項就是整個清單回應的主要體積
$sortable 只該開在資料量有上限的資源上 拖曳排序會把整組載進來跑重排演算法
重排用最長遞增子序列找錨點,tie-break 是次佳選擇 最壞情況會把「只改一列」變成「整組重編」
contains / endsWith 走 ILIKE,本來就用不到 B-tree;startsWith 也走 ILIKE,同樣用不到 要就自己加 lower(col) 運算式索引或 pg_trgm GIN
api/common/city、menu 與 page 沒有快取,而且是 POST CDN / proxy 快取不適用,要快取只能做在應用層
三個 disk 設定只能用 local driver 縮圖產生與 IP 資料庫讀取走的是原生檔案函式(is_file、mkdir、file_put_contents、rename),它們吃的是 Storage::disk()->path() 的回傳值——那個值對非 local driver 是相對路徑,會相對於行程當下的工作目錄解析,而 queue worker、artisan 指令與 php-fpm 的工作目錄各不相同,同一份縮圖可能寫在三個地方、讀的時候都找不到,而且全程沒有錯誤。套件本身也沒有安裝任何遠端 flysystem adapter。設成別的 driver 會在取用 disk 時直接回 unsupported-disk-driver
Resources 是 singleton Octane 這類長生命週期行程改了設定要重啟才看得到。訊息 worker 例外——SendMessageJob 每次執行前會先丟掉已快取的 bundle,所以在資源後台改了 cfg(SMTP 主機/帳密、interval 等)下一封信就生效,不必重啟 worker
級聯刪除會逐筆取出實例再刪(為了稽核) 成本隨子資料列數成長
刪除前的參照檢查,最上層是每個關聯一次分組計數,關聯條件不能依賴 model 屬性 要刪的那幾筆共用同一個關聯查詢(以第一筆建立、whereIn 全部的鍵),所以 hasMany(X::class)->where('type', $this->type) 這種依各筆屬性加條件的關聯會算錯數量——可能讓仍被引用的資料被刪掉。常數條件與 global scope(含軟刪除)不受影響。查詢帶 limit / offset / group / having / union / join(例如 latestOfMany())的關聯,以及級聯刪除底下的子層,仍逐筆 count()
級聯刪除的路徑可以在任何一層分岔 cascade(['children.a', 'children.b']) 依共同前綴合併:每個 children 只走訪一次,a、b 兩種孫資料都先刪掉、都不擋刪除;沒列進去的關聯有資料仍回 data-in-use

資料生命週期

事實 你要做什麼
matrix:prune-tokens 只清 base_auth_token 另外八張只增不減的表要自己來:base_manipulation_log、base_user_log、base_member_log、base_vendor_log、base_mail_log、base_sms_log、base_push_log、base_telegram_log
前四張只有 create_time(沒有 update_time);後四張兩個都有 清理判準只能用 create_time
base_mail_log / base_sms_log / base_push_log / base_telegram_log 只能刪終端狀態(成功 / 失敗) Scheduled 是還沒送出的排程,刪掉等於取消一封信
base_file 不要用時間清 刪列不刪磁碟檔會漏儲存空間,而去重讓一筆記錄可能被多處引用 —— 套件答不出「誰可以刪」
base_drive_node 完全沒有永久刪除——drive/{id}/delete 只是軟刪除(deleted_at),node 與實體檔案永遠不會真的消失 這是刻意的決定,不是漏做垃圾清除;資料庫與磁碟用量只會隨使用量增加,規劃容量時要算進去
調大 token-idle-minutes 不會復活已經被清掉的 token 要調大就先調、再跑 prune
token-idle-minutes 的 min:1 驗證只擋資源後台 自己在 resources/cfg/admin.php 寫 0 不受檢查,結果是全員登出、prune 清空整張表

行為細節

請求與交易

事實 說明
每個 #[Action] 都要有選單節點 漏一個,那個端點對所有人 403,包含 ROOT
每一個 action 都跑在一個交易裡 BaseController::callAction() 用 DB::transaction() 包住整個動作。要在 rollback 之後仍然執行的副作用(寄信、打第三方、刪檔)請註冊到 RollbackCallbacks,不要直接做:register() 是撤銷,只在登記當下那一層交易 rollback 時執行(巢狀交易內層 rollback 不會動到外層登記的;交易外登記不會執行);persist() 是一定要留下的寫入(例如登入失敗紀錄),交易 commit 或 rollback 後執行一次,交易外則立即執行
#[Action] 會沿繼承鏈繼承 覆寫 action 不需要重新宣告 attribute
套件的 controller 可以整個換成子類別 路由掛的是套件的類別,但 controller 由容器建立,所以 $app->bind(MailLogController::class, App\...\MailLogController::class) 就會改用子類別(例如改 $lists、加 guard);要加欄位就另外 app(MetadataRegistry::class)->register(MailLog::class, new 子類別宣告()),放在 $app->booted() 裡
#[Action(encrypted: false)] 讓那支 action 不受所屬前綴的加密開關約束 呼叫方不可能封信封的端點要標記它:出貨已標的是 telegram webhook、兩支 multipart 上傳(file/upload、drive/{id}/upload)與 encryption-key。自己的上傳端點也要自己標,漏標的症狀是開關一開就變 invalid-envelope
篩選值的格式與型別都會驗證 op 要的是單一值卻送陣列(eq / contains / between 的 from、to 等)、in / notIn 的清單裡有陣列,一律回 422 invalid-filter-value。值也要對得上欄位型別:integer 要整數或純數字字串、float 要 numeric、boolean 要 true/false/0/1、date 要完全符合 matrix.date-format、datetime 要完全符合 matrix.datetime-format(兩者不互通,也不收 now、tomorrow、+1 day 這類 PostgreSQL 或 PHP 認得的相對值,以及 2026-02-31 這種會被 PHP 悄悄進位的日期),不符同樣回 422。這一道是必要的——不擋的話那個值會原樣進 SQL,PostgreSQL 丟 22P02,而 action 跑在交易裡,整個交易會進 aborted 狀態(25P02),該請求之後每一個查詢都失敗,真正的原因還被蓋掉。型別依的是欄位宣告的型別(DSL 標註 → 聚合函式 → #[Declared] 的 definition → model cast),都沒有就當 text 不檢查。欄位或 op 不被允許則靜默忽略,in 清單裡的 null 不擋(合法 SQL,永不匹配)
巢狀資源(非 standalone)的每個 CRUD 動作都會驗證路由上的父層鏈 所有查詢(清單、get、update、delete 含多筆 id、copy、排序、快速上下架、匯出)都加上直接父層的外鍵條件,不屬於該父層的 id 回 404 data-not-found(多筆刪除只要有一筆不屬於就整批拒絕)。另外在第一次取用父層外鍵時(CrudService::owner()),沿 Subject::parent() 往上逐層驗證:父層要能經由它自己的 model 查到(套用其 global scope 與軟刪除),且它的外鍵要等於路由上更上一層的參數,否則同樣回 404——所以父層不存在、被軟刪除、被 global scope 排除(例如共用表依類別分的 model),或祖父層參數對不上時,清單、新增頁、新增、匯入也一律 404。路由沒帶的上層參數不驗證;自我參照的樹(menu/{parent_id}/children)只驗證直接父層存在。standalone 模式不套用任何父層條件。admin/schedule/toggle 不走巢狀路由,只依 prefix 權限與 model 本身的 global scope 取資料
子清單的條件不是父層外鍵時,用 $mount 指定動作前綴 model 的 metadata 沒有父層(或父層不是路由上那一層),例如依行程所屬的群組列出資料,只能用 standalone 並在 onList() 等處自己加 scope();這時動作 url 與權限節點仍依 metadata 推導({alias}/{id}),會連到另一個資源。在 controller 設 protected ?string $mount = 'tour/{tour_id}/reviews';(對應 CrudService::mount())後,所有動作 url 與權限檢查改用這個前綴,欄位偏好的 key 也跟著它。前綴裡的 {tour_id} 照常由前端用該列(列動作)或信封的 context(頁動作)展開,所以列資料與 context 要自己帶上這個值。admin/schedule/toggle 的權限仍看 model metadata 推導的前綴
樂觀鎖要自己呼叫 BaseModel::lock() 會重讀該列並逐欄比對,值被別人改過就回 data-conflicted。CRUD 引擎不會自動幫你呼叫
AdminPermission 在信封範圍外解析會靜默失敗 在 web 路由、console、queue job 裡解析它,ServiceException 不會被回報

權限與帳號

事實 說明
權限樹只認四個動作 query / insert / update / delete。選單節點上寫別的 tag(例如 system)不會變成可勾選的權限項目
手寫進資料庫的權限,形狀錯了會被靜默丟掉 存進去的形狀必須是 {"路徑": {"動作": true}}。值只認 true、1、"1"(寫入時一律存成 true),其他值(包括 "true"、"false" 這類字串)的項目在下一次寫入時就消失,而且不會有任何錯誤
權限的寫入是「範圍內修訂」 編輯者只能授出自己有的權限,也洗不掉自己碰不到的;白名單以外的權限(例如維運用 SQL 寫的)會被保留
guard 是疊加的,套件先、宿主後 宿主看到的是已經過濾的值,而且無法覆蓋套件自己的 guard(例如「不能刪自己」)
user 的 copy 與 export 同受等級範圍約束 與 get / update / delete 一樣,管不到的帳號一律 data-not-found、匯不出來。copy、export 都是宿主加選單節點就開啟;sort 對 user 不可用(base_user 沒有 ranking 欄位)。group 不受等級範圍約束
password 的 key 一定要送,值可以留空 完全不送 key 會回 422 present(更新是全量覆寫,漏送等於前端壞了)。送 null 或空字串則不寫入 —— 編輯保留原密碼與 session,新增建出沒有密碼、登不進來的帳號。只有非空值才必須符合 admin.password-pattern,編輯成功後撤銷該帳號全部 session
預先雜湊的值當密碼會被原樣存進去 政策只有 admin.password-pattern 一道,而 bcrypt hash 通過它(60 碼、含英文與數字)。hashed cast 對已雜湊的值不再雜湊,所以前端若先雜湊再送,該帳號之後得拿 hash 字串當密碼才能登入,而且沒有任何錯誤訊息
whereActive() 把 enable_time 為 NULL 的列一律當成未啟用 對 user 的表徵是「後台建的帳號沒填啟用時間就登不進來」(enable_time 要非 null 且已到,而表單沒把它設成必填,症狀是「帳號或密碼錯誤」,看不出真正原因);對 base_menu 的表徵是 api/common/menu(匿名端點)靜默回空選單 —— 沒有報錯路徑,debug 起來毫無線索

複製、匯出與排序

事實 說明
清單預設按鈕是 pageActions(['new', 'delete', 'arrange', 'sort', 'export', 'import'])、rowActions(['edit', 'copy', 'delete'])(可追蹤的 model 再補 log) 每一顆都要選單有對應節點({prefix}/export、{prefix}/import、{prefix}/{id}/copy 等)且使用者有權限才會出現,所以開不開複製/匯出/匯入由選單決定——端點本身也一樣:沒有節點的 export / import / {id}/copy 不會註冊,請求回 endpoint-not-found(含 ROOT),controller 不需要另開旗標;匯入另外要 controller use ImportsRows
複製會沿用來源的 ranking 除非那個資源開了 $sortable,否則不會自我修復
複製時 $generators 管的欄位(建立時間、建立者等)重新產生,不照抄 ——
級聯複製只接受 hasOne / hasMany 及其 morph 形式 belongsToMany 不支援
匯出明寫 'id' 匯不出主鍵 要主鍵請寫 'key=id'
匯入只新增 檔案裡的 id 欄忽略;不做更新或 upsert
匯入時前面的列照樣寫入,最後才整批退回 為了讓 unique 與 guard 抓到檔案內自己重複的值。每列包一層 savepoint,資料庫錯誤只記在那一列(* / query-failed),後面的列照常處理
資料庫不允許 NULL 的選填欄位,匯入空白會失敗 空白寫入 null(布林欄除外,寫 false)。這種欄位要設預設值或改成必填,否則該列報 query-failed
匯入的表頭與選項以目前介面語系的標題完全比對 範本與匯入要用同一個介面語系;樹狀選項攤平後不同父層同名(例如兩個「其他」)會回 in,所以含同名節點的匯出檔無法原樣匯入。跨 model 的選項樹(例如國家 > 城市)只有目標 model 的節點可反查(Option::$selectable)
匯入不支援多選欄位 存成 Json 的多選收不了標題,存成 Text 的驗證規則是 string,第一版一律排除
$exports = [] 是「沒有欄位」,不是「退回清單欄位」 ——
多語欄位用 ExportService::locales([...]) 展開時,表頭帶語系名稱 每個語系一欄({欄位}__{語系}),title 依 backend.export.locale-column 組成,例如「問題(繁體中文)」、英文介面「Question (English)」;語系名稱取 backend.locale-{語系}。沒呼叫 locales() 時只輸出目前語系一欄,表頭不變
$hidden 只對 root model 的欄位有效 join 進來的別名不受它保護
匯出回應的 columns[] 不含 op / sortable / options 前端要知道能篩什麼,必須先呼叫清單端點
base_city_area.ranking 與 base_ranking 序列不同量級 後台第一次拖曳排序就會把整組重編
巢狀資源的父層鏈驗證用的是父層 model 的預設查詢 每個請求對路由上的每一層父層各多一次查詢(admin/page/{page_id}/block/{block_id}/item 多兩次)。父層被軟刪除或被自己的 global scope 排除時,底下的子資料在後台整組變成 404;要讓後台操作這種父層底下的資料,得在父層 model 調整 scope,或改用 standalone 並自己加條件。admin/page/999/block/31/item 這種祖父層對不上的網址也回 404,不會再顯示網址與麵包屑不符的資料
count() 的鑽取路徑只把最後一個佔位符換成 {id} ColumnResolver::path() 推導出前綴後會經過 Subject::generic(),它只替換最後一個佔位符:一層巢狀是 city/{id}/area,兩層是 page/{page_id}/block/{id}/item。前端是拿那一列去填佔位符的,而列本身帶著自己的外鍵(page_id)與 id,所以具名佔位符填得滿,兩層以上也不必自己寫。推導不出來時(選單沒有對應節點)path 是 null;要指定別的路徑,在 $lists 用陣列語法明寫 path,明寫的值直接送出,不經過 generic() 也不查 Menus::has(),代價是這個字串與路由、選單三處必須人工保持一致,寫錯的症狀是點數字連到 404
$counts 在設了 $lists 或 $updates 的 controller 上會靜默失效 CrudController::listing() 只有在 $lists 為 null 且 $updates 為空時才把 counted() 併進清單。設了其中任何一個,$counts 就完全沒有作用,也不會有任何警告。要 count 欄位就直接寫進 $lists('count(items)'),不要用 $counts
自我參照的樹,巢狀資源必須掛在與關聯名同名的路由段下 count(children) 的鑽取路徑是這樣推出來的:Subject::path() 遇到自我參照會停在 alias(menu),引擎改用關聯名 children 去選單裡找 menu/{任意參數}/children,找到才有 path。掛成別的名字(例如 menu/{parent_id}/sub)不會壞,但 path 會是 null,前端的數字就點不進去。admin/menu 只列頂層,子層一律走 admin/menu/{parent_id}/children,每一層都回到同一個 pattern,深度不限

資料寫入與稽核

事實 說明
上傳是內容去重的 hash + size + privilege + usage 相同就是同一筆,回傳既有紀錄,name 保留第一次上傳的檔名。磁碟上的檔案被外部刪掉時不會命中去重,會重新寫一份新紀錄
對套件的 model 下批次 delete() / update() 會靜默失去稽核 query builder 的批次操作不觸發 model 事件。要稽核就取出實例逐筆處理
稽核紀錄只寫入,不能修改或刪除 ManipulationLog(與繼承它的 model)save() 既有列或 delete() 丟 LogicException。只擋 model:批次 update()/delete() 與直接 SQL 不經過 model 事件,不受限制,清除或修正要走這條路
稽核紀錄的 after 是 accessor 之後的值 你掛在可追蹤欄位上的 accessor 從此決定稽核內容
Operator 掛在檢視表上 唯讀,不要對它 save()
base_drive_node 沒有 deleter_id 欄位 API 回應的 deleted_by 是即時查 base_manipulation_log(data_type/data_id 對上該節點、type = Deleted 的最新一筆)反查出來的,只在 deleted_at 非空時才查一次;未刪除的節點固定回 null,不打這張表

暫存子項目的取捨

事實 說明
跨列規則請放在 validateStaged(),guard 只放單列規則 按「確定」時的單列驗證,以及存檔時逐列寫入,guard 看到的都是「舊的」或「寫到一半」的資料庫。例如「刪掉房型 X 再新增 X」,查兄弟列的 guard 在按確定時就會擋下
刪掉 A 再新增同值的 A 可以成功 同一個 key 內先刪除再新增,而且不預先驗證全部列(預先驗證會在刪除前就查 unique)
兩列互換單一 unique 欄位的值一定失敗 不管處理順序如何,第一列寫入時另一列仍佔著那個值
新增頁的 validate,guard 看到的外鍵是 null 父資料還沒存;存檔時才 attach 真正的父 id
子資源的 options、guard、onX 讀 request() 拿不到子資源的路由參數 值是 null 而且不報錯。改讀 StagedContext
子 controller 覆寫的 insert() / update() 不會被呼叫 框架只呼叫 service
沒實作 WritesStagedRows 的子資源,新列與改過的列看不到列表專屬的值 它的 validate 不寫入、回 [],所以表格裡這些列只有表單值;列表專屬欄位(join、guard 補的值)要存檔後重新載入才有
afterStaged() 在交易內執行 外部副作用撤不回,請用 DB::afterCommit()
多人同時編輯同一筆的子項目是後存覆蓋 沒有樂觀鎖
實作 WritesStagedRows 時,寫入方法要自己處理業務檢查 框架不呼叫子 service,子資源的欄位驗證、guard、unique 都不會自動執行;也不檢查傳入的 id 是否屬於父資料。寫入方法不可讀路由參數:暫存模式下路由的 id 是父資源的 id。父資料改讀 StagedContext
暫存模式只有新增、修改、刪除與排序 上下架、匯入、匯出、異動紀錄等子資源功能不在暫存模式裡;子資源原本的獨立端點不受影響,異動紀錄照子 service 的寫入流程每列各留一筆

選單與回應契約

事實 說明
麵包屑的 label 在多層資源下可能與直覺不同,title 三層都正確 已用測試釘住
context.{父層}_id 是字串,而 rows 裡的 id 是整數 前端比對時要注意型別
getMenuNodes() 的每個節點一定有 group 與 tag(可能是 false / null) 用 empty() 判斷即可
base_menu 的迴圈與孤兒節點會被靜默丟掉 資料完整性由你負責,不會有錯誤訊息
上下架篩選的「現在」以 PHP now()(app 時區)綁定 不用資料庫的 NOW(),因為 timestamp without time zone 欄位是以 app 時區寫入;結果是查詢當下的快照
whereActive()/whereExpired()/whereNotExpired() 的欄位一律加 model 表名 以 qualifyColumn() 補成 表.欄位,查詢 join 了同樣有 enable_time 的表也不會 ambiguous;已帶表名(含 .)的欄位原樣使用。條件只套在 model 自己的表,要判斷 join 進來的表請在 whereHas() 裡呼叫