Laravel 套件化系列第一篇,從金流退款談職責該如何劃分,再從「紅包」改名「點數連結」談命名該多中立。
前言
這將會是一個系列,我將在這系列分享推動團隊套件化的心路歷程, 範例程式碼部分將一律使用 PHP 的 Laravel 框架展示, 但不包含基礎配置,僅著重在觀點、手法及踩坑經驗。
我共待過兩個接案型的團隊, 期間總是一直發生重複基於相似功能改寫並交付的狀況, 這讓我萌生了將功能套件化、建立私有技術資產的念頭, 截至 2026/10/01 已累積 35 個以上的主導、規劃或協作套件化經驗。
快速連結
怎麼開始?
製作套件並不單是把功能打包封裝就好, 若套件包山包海,未來會變得難以維護。
因此我認為製作套件最重要的一步,也是第一步, 就是劃定主題、釐清職責,什麼該做、什麼不該做。
職責太大,在某情境下可能是多餘、不該做的, 職責太小,又可能在另一情境需重新補齊功能。
主題與職責
劃定主題與釐清職責前必須先強調, 「沒有銀彈般的劃分方法,一切以滿足需求為優先」。
為什麼這麼說? 因為同一段邏輯,在 A 專案可能是必要的,在 B 專案卻是多餘的。
假設你的團隊多數專案都有點數機制,
然而也有不少專案較為單純,完全沒有任何點數,
若不把職責劃分清楚,那麼就會到處都是 if-else。
以下僅展示金流套件「整筆」退款情境,省略部分退款及其他邏輯。
⚠️職責太大
public function refund(): void
{
if ($this->status !== PaymentStatus::Captured) {
throw new PaymentNotRefundable();
}
$this->status = PaymentStatus::Refunded;
$this->payer->revokePoints($this->id); // ⚠️ 收回已贈送的點數,但部分專案根本沒有點數機制!
}
⚠️職責太小
public function refund(): void
{
if ($this->status !== PaymentStatus::Captured) {
throw new PaymentNotRefundable();
}
$this->status = PaymentStatus::Refunded;
// ⚠️ 不處理點數收回且連通知都不給,導致多數專案得在每次退款後自行補上!
}
⚠️折衷方案
public function refund(): void
{
if ($this->status !== PaymentStatus::Captured) {
throw new PaymentNotRefundable();
}
$this->status = PaymentStatus::Refunded;
if (class_exists(YourTeam\Packages\Point::class)) {
// 點數處理邏輯
}
// ⚠️ 未來可能長出更多 if-else
}
我可能會這樣設計
public function refund(): void
{
if ($this->status !== PaymentStatus::Captured) {
throw new PaymentNotRefundable();
}
$this->changeStatusTo(PaymentStatus::Refunded); // 標記此交易為已退款
}
private function changeStatusTo(PaymentStatus $status): void
{
$this->status = $status;
$this->pendingEvents[] = $status->event($this->id);
}
public function commit(): void
{
// ...省略持久化邏輯程式碼,在此才算真的完成異動流程,可發出事件
// 題外話:若外層包著交易,應改為提交後才送出,如:afterCommit()
while ($this->pendingEvents !== []) {
event(array_shift($this->pendingEvents));
}
}
只更改狀態並對外發布狀態變動事件,僅此而已。
為什麼? 因為金流若處理點數,便必須開始依賴點數邏輯或套件, 且外部可能因不知道執行退款竟會收回點數而重複處理; 反之,若金流職責太小,連狀態改變都不通知,外部就只能自行判斷並處理。
再者,若不是購物邏輯呢? 捐贈捐款、繳交報名費...等許多都與點數無關, 它們需要知道的都只是「這筆交易被退款了」。
因此我選擇將其視為後置行為由外部處理, 設計金流邏輯時,不該知道更不該處理其他議題, 非自身職責邏輯大可透過發布事件讓關注方去處理。
語意
這個章節同樣是我認為很重要但被多數人輕視的, 短期內不注重不會怎樣,但程式碼卻會慢慢腐敗, 尤其在 vibe coding 盛行的年代我認為更加重要, 畢竟 AI 只能透過提示詞與程式碼語意了解開發者需求。
以上一個章節中提到的點數為例,
正好前陣子我剛從舊案中切割並規劃點數套件,
其中一個功能叫做「發放紅包」,
然而我並沒有沿用舊有名稱 RedEnvelope,
而是用看起來有點反直覺的 PointsLink。
首先,試想紅包一詞有什麼特徵?
節慶、正項(僅贈不收)。
光這兩項就足以否決以紅包命名了, 因為適用的情境太侷限。
想辦活動也發連結讓人搶點數呢?活動等於節慶嗎? 玩遊戲想設陷阱製造笑點效果呢?僅正項能辦到嗎?
這種綁定情境或流程的命名稱為狹隘, 然而太過於籠統的命名又會變成空泛, 這兩種都不利於團隊開發及長期維護。
狹隘
| 類型 | 命名 |
|---|---|
| 類別 | RedEnvelope、NewYearBonus |
| 方法 | sendToLineChannel()、giveGiftPoints()、claimAfterLogin() |
| 變數 | $giftAmount、$springFestivalExpiresAt、$claimedLineUserIds |
綁死情境,一旦需求超出原始範疇便開始失效, 只能改名或另建新方法,更糟糕的是將就沿用。
空泛
| 類型 | 命名 |
|---|---|
| 類別 | Link、MemberExtra |
| 方法 | handle()、process()、execute() |
| 變數 | $data、$value、$type |
意圖模糊,人類與 AI 都只能親自讀過才知道作用, 當同類命名越來越多,也將難以分辨差異。
中立
| 類型 | 命名 |
|---|---|
| 類別 | PointsLink、PointsTransaction |
| 方法 | issue()、trigger()、revoke() |
| 變數 | $amount、$expiresAt、$triggererId |
以描述機制而非用途命名,能直觀透過名稱了解意圖或行為, 當需求擴展時名稱依然成立,無須改名或另建新方法。
命名中立使得隨時可發、正負皆可特徵能自然成立。
且先前提到的負項需求後來真的發生了, 被應用在某個踩地雷小遊戲中作為陷阱, 也被用於扣除觸發者點數轉回給發放者的功能, 兩者都未受「紅包」語意限制,無須任何改寫。
語意中立的重要性也不只體現在設計上, 它還影響著專案的各個環節,如溝通、文件等非技術領域的面向, 注意,這邊說的是專案而不僅是程式碼。
若我當時選擇沿用「紅包」命名, 溝通方面,可能讓雙方討論時不自覺以節慶專屬為前提。 文件方面,可能使閱讀規格書的人誤以為只需開發正項。 報表方面,當負項出現時,閱讀者可能會覺得資料出錯。
但你可能會好奇:
Q. 使用者就是習慣以紅包稱呼,怎麼辦? A. 套件內維持中立,UI 上照樣可以顯示「紅包」,重要的是判斷「當前對象是誰」。
Q. 所有命名都要中立嗎? A. 不是,若主題、情境非常明確,刻意狹隘也是一種選擇,例如專案專屬的春節活動模組。 套件因跨情境需求而傾向中立,但專案只服務自己,因此可以更具體。
