iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Modern Web

我推的Laravel S2!系列 第 23

我推的Laravel S2|Day 22:寫出乾淨的 Laravel 程式碼:PSR、命名規範、OOP 與 SOLID 原則

  • 分享至 

  • xImage
  •  

一句話破題

今天把 Coding Style/PSR 跟 OOP/SOLID 合併成一天。這不是為了省篇幅隨便湊——這兩個主題本來就是同一件事的不同層次:PSR 講的是「程式碼長什麼樣子」的表層風格,OOP/SOLID 講的是「程式碼怎麼組織」的結構性原則,兩者合起來才是「乾淨的程式碼」完整的樣貌。這塊沒有版本差異,是可以純粹談觀念的一天。

Coding Style 為什麼重要

程式碼被讀的次數遠比被寫的次數多。風格一致的程式碼降低團隊協作的認知負擔、減少不必要的 code review 來回、也讓工具(IDE、靜態分析)能更好地輔助開發。

PSR:官方文件怎麼說、實務上怎麼做

Laravel 官方 contribution guide 目前文字上寫的是「遵循 PSR-2 coding standard、PSR-4 autoloading standard」,這句話從很早期的版本就沒改過。但這裡有個重要的實務現況要知道:PSR-2 本身已經在 2019 年被官方棄用,由 PSR-12 取代,PSR-12 後來又被 PER Coding Style 接手維護以跟上 PHP 語言的新特性

那 Laravel 專案到底該遵循哪個?答案是:不用自己摳規則細節,交給 Laravel Pint 處理。Pint 是 Laravel 官方的程式碼風格自動修正工具,每個新專案都內建:

./vendor/bin/pint          # 修正所有檔案
./vendor/bin/pint --dirty  # 只修正尚未提交的變更(適合 commit 前跑一次)
./vendor/bin/pint --test   # 只檢查、不修改,適合放進 CI

Pint 底層可以選擇多種 preset(laravelperpsr12symfony 等),預設用的是 Laravel 自家風格(精神上貼近 PER Coding Style/PSR-12)。實務上你不需要背熟 PSR-2 或 PSR-12 條文細節,寫完程式碼跑一次 pint,剩下的交給工具。

客製化 Pint 規則

如果團隊對某些規則有特定偏好(例如強制陣列語法一定要用短語法 [] 而不是 array()),可以在專案根目錄放一個 pint.json

{
    "preset": "laravel",
    "rules": {
        "array_syntax": { "syntax": "short" },
        "no_unused_imports": true,
        "ordered_imports": { "sort_algorithm": "alpha" }
    }
}

pint.json 一旦提交進版本控制,團隊每個人(以及 CI 流程)跑 pint 時都會套用同一套規則,不需要各自在 IDE 設定裡各自調整——這也是為什麼 Day 3 建議的 VS Code 設定裡加了「儲存時自動跑 Pint」,讓風格規則的執行完全自動化,不依賴人為記得手動執行。

更進一步:靜態分析工具

Pint 處理的是「風格」(程式碼長什麼樣子),但不會告訴你「這段程式碼邏輯上有沒有問題」(例如呼叫了一個可能不存在的方法、型別不匹配)。這是靜態分析工具要解決的問題,Laravel 生態圈最常見的選擇是 Larastan(PHPStan 的 Laravel 專用擴充):

composer require --dev larastan/larastan
./vendor/bin/phpstan analyse

Larastan 能在執行程式碼之前就抓出很多類型的錯誤,例如呼叫了 Model 上不存在的屬性、方法回傳型別跟宣告不符。它有分析等級(level 0 到 9,數字越大檢查越嚴格),新專案建議從較低等級開始,逐步拉高,不要一開始就設定成最嚴格等級——那樣通常會產生大量一時難以處理的警告,反而讓團隊失去導入的意願。

變數與命名規則

Laravel 生態圈的命名慣例大致統一:

對象 命名法 範例
變數、方法 小駝峰(camelCase) $publishedAtgetPost()
類別、Enum 大駝峰(PascalCase) PostControllerPostStatus
資料表、欄位 蛇式(snake_case),資料表用複數 postspublished_at
路由 URI 中線(kebab-case) /posts/{post}/toggle-featured

變數命名本身也是一種溝通——$p$data 這種命名雖然打字快,但半年後回頭看(或別人接手)幾乎等於重新猜一次這個變數是什麼;$post$publishedPosts 這種具名變數,讓程式碼本身就是文件。

註解:寫「為什麼」,不寫「做什麼」

// 不好的註解:只是把程式碼翻譯成中文,沒有額外資訊
// 把 is_featured 設成 true
$post->is_featured = true;

// 好的註解:解釋「為什麼」,程式碼本身看不出來的脈絡
// 手動精選文章需要繞過自動推薦演算法的權重計算,因此直接設定旗標
$post->is_featured = true;

回傳型別宣告與現代 PHP 語法

public function getPublishedPosts(): Collection
{
    return Post::whereNotNull('published_at')->get();
}

public function findPost(int $id): ?Post
{
    return Post::find($id);
}

明確標註回傳型別(Collection?Post 這種可為 null 的型別),除了讓 IDE 能提供更準確的自動完成,也讓呼叫端不需要看方法內容就知道要處理什麼型別,Day 9 建立關聯方法時已經在用這個習慣(: BelongsTo: HasMany)。

PHP 8.1+ 引入的 readonly 屬性也值得養成使用習慣,特別適合 Day 18 的自訂例外、Day 17 表達「這個值一旦建構就不該再變」的場景:

class InvalidPostStateException extends Exception
{
    public function __construct(
        public readonly int $postId,   // 建構後任何地方想改這個屬性,會直接在編譯期被擋下來
        string $message = '文章目前狀態不允許此操作',
    ) {
        parent::__construct($message);
    }
}

跟只在文件裡寫「這個屬性不該被修改」相比,readonly 讓這個約束變成語言本身會強制執行的規則——這是型別系統能替你在寫程式的當下就抓到錯誤的又一個具體例子(跟 Day 11 提到的 Enum 轉型是同一個精神)。

OOP 基礎:Laravel 本身就是活教材

物件導向的四大特性,Laravel 框架本身的設計就是現成的範例:

封裝(Encapsulation)

把內部實作細節藏起來,只透過明確定義的介面互動:

class Post extends Model
{
    protected $fillable = ['title', 'body'];  // 只有這些屬性能被外部批量賦值

    public function publish(): void
    {
        $this->published_at = now();
        $this->save();
    }
}

// 外部程式碼不需要知道「發布」內部是設定哪個欄位、要不要額外處理
$post->publish();

繼承(Inheritance)

PostController extends ControllerPost extends Model——這系列從 Day 9 開始寫的每個 Model、Controller,本身就是繼承的實際應用,子類別自動獲得父類別的能力(Model 提供的 save()/find() 等),再加上自己的專屬邏輯。

多型(Polymorphism)

同一個介面,不同實作可以有不同行為。Laravel 的 Route Model Binding(Day 13)就是一種多型的應用:不管你綁定的是 PostUser 還是任何其他 Model,Laravel 都用同一套機制處理「依 ID 查詢並注入」,不需要為每種 Model 寫一套專屬邏輯。

抽象(Abstraction)

定義「該做什麼」而不是「怎麼做」,透過 Interface 表達:

interface NotificationChannel
{
    public function send(string $message, User $recipient): void;
}

class EmailNotificationChannel implements NotificationChannel
{
    public function send(string $message, User $recipient): void
    {
        Mail::to($recipient)->send(new GenericNotification($message));
    }
}

class SlackNotificationChannel implements NotificationChannel
{
    public function send(string $message, User $recipient): void
    {
        // 呼叫 Slack API...
    }
}

呼叫端只需要依賴 NotificationChannel 這個抽象介面,不需要知道背後究竟是 Email 還是 Slack 在處理——這也是 Day 23 Service Container 會深入的「依賴反轉」的基礎。

PostController 依賴 NotificationChannel 這個抽象介面,而不是直接依賴 EmailNotificationChannel 或 SlackNotificationChannel 任一具體實作

組合優於繼承:一個具體的重構案例

物件導向教學常強調繼承,但實務上很多時候「組合」(一個類別擁有另一個類別的實例、把工作委派給它)比繼承更靈活。假設你一開始這樣設計:

// 不理想:用繼承表達「這個 Post 是可加精選的」
class FeaturablePost extends Post
{
    public function feature(): void
    {
        $this->update(['is_featured' => true]);
    }
}

問題是「加精選」這個能力如果之後也想套用在 Comment(精選留言)上,繼承完全幫不上忙——你不能讓 Comment 也繼承 FeaturablePostComment 又不是一種 Post)。改用組合:

// 更理想:獨立出一個負責處理的類別,Post/Comment 各自持有它、委派工作給它
class FeaturableManager
{
    public function feature(Model $model): void
    {
        $model->update(['is_featured' => true]);
    }
}

或者用 PHP 的 Trait 達到類似效果(讓多個不相關的類別共享同一段行為,而不需要共同的繼承鏈):

trait Featurable
{
    public function feature(): void
    {
        $this->update(['is_featured' => true]);
    }
}

class Post extends Model
{
    use Featurable;
}

判斷原則:「這個類別是不是另一個類別的一種」用繼承(Admin extends User 合理,因為管理員本質上就是一種使用者);「這個類別需要另一個類別提供的能力,但彼此本質上是不同的東西」用組合或 Trait。當你發現繼承鏈越疊越深、子類別只為了改一兩個方法而存在時,這通常是該考慮改用組合的訊號。

SOLID 五大原則:一句話+一個 Laravel 情境

單一職責原則(SRP)

一個類別只該有一個改變的理由。PostController 只負責處理 HTTP 請求/回應,不該同時塞進「怎麼寄通知信」「怎麼算文章熱門分數」這些邏輯——這正是 Day 19 把通知邏輯抽成獨立 Job、Day 23 會介紹的 Service 類別存在的理由。

一個違反 SRP 的反例

// 不理想:一個方法同時處理驗證、寫入資料庫、寄信、記錄日誌四件事
public function store(Request $request)
{
    $validated = $request->validate([...]);
    $post = Post::create($validated);
    Mail::to($post->author->subscribers)->send(new PostPublishedMail($post));
    Log::info('文章已建立', ['post_id' => $post->id]);
    return redirect()->route('posts.show', $post);
}

這個方法「改變的理由」至少有四個:驗證規則變了要改、儲存邏輯變了要改、通知方式變了要改、日誌格式變了要改。拆開之後(用 Day 17 的 Form Request 處理驗證、Day 19 的 Job 處理通知、Day 23 的 Service 處理業務邏輯),store() 方法會回歸到它真正該做的事:協調各個部分,而不是自己動手做所有事情。

開放封閉原則(OCP)

對擴充開放、對修改封閉。前面 NotificationChannel 的例子:想加一個新的通知管道(例如 LINE Notify),只需要新增一個實作類別,不需要修改既有的 EmailNotificationChannel/SlackNotificationChannel

里氏替換原則(LSP)

子類別必須能夠替換父類別使用,不改變程式的正確性。如果 PremiumUser extends User 但覆寫了 posts() 方法讓行為完全不符合 hasMany 關聯的預期(例如回傳型別不一致),任何原本預期操作 User 的程式碼換成 PremiumUser 就可能出錯,這違反了 LSP。

介面分離原則(ISP)

不要強迫類別實作它用不到的方法。與其定義一個包山包海的 Payable 介面(charge()refund()subscribe()cancelSubscription()……),拆成 ChargeableRefundableSubscribable 幾個小介面,讓每個類別只實作它真正需要的部分。

依賴反轉原則(DIP)

高階模組不該依賴低階模組的具體實作,兩者都該依賴抽象。延續前面的例子,PostController 依賴 NotificationChannel 這個介面,而不是直接依賴 EmailNotificationChannel 這個具體類別——Day 23 的 Service Container 綁定機制,就是 Laravel 落實 DIP 的核心工具。

常見錯誤與踩雷點

  • PSR 版本焦慮:不需要糾結「我到底該用 PSR-2 還是 PSR-12」,交給 Laravel Pint 自動處理,把心力放在程式邏輯本身。
  • 為了套用 SOLID 而過度設計blog-app 這種規模的專案,不需要每個功能都刻意拆出 Interface + 多個實作類別,SOLID 是「當複雜度需要時」的工具,不是無論如何都要套用的規則,Day 23 會再討論這個取捨。
  • 繼承濫用,該用組合的地方硬用繼承:物件導向教學常強調繼承,但實務上「這個類別擁有另一個類別的能力」(組合)往往比「這個類別是另一個類別的一種」(繼承)更靈活,尤其當你發現繼承鏈越疊越深、子類別只為了改一兩個方法而存在時,該考慮改用組合。
  • 一次導入 Larastan 最嚴格等級,團隊被大量警告嚇跑:靜態分析工具的價值在於長期持續使用,從較低等級開始逐步拉高,比一開始就要求完美更容易讓團隊真正養成習慣。

小結

今天把程式碼風格(PSR、命名、註解、型別宣告、readonly)跟程式碼結構(OOP 四大特性、組合優於繼承、SOLID 五大原則)合併整理,也補上了 Pint 客製化設定跟 Larastan 靜態分析這兩個實務上能大幅提升程式碼品質的工具。這兩塊在 Laravel 10 到 13 之間相當穩定,唯一值得更新認知的是「PSR 版本焦慮交給 Pint 處理」這個實務現況。

不以規矩,不能成方圓 — 《孟子・離婁上》

明日預告

Day 23 把 SOLID 的依賴反轉原則落地:Service Container 怎麼運作、Service Provider 怎麼組織,最後用 blog-app 文章列表的 Service + Repository 分層架構,把今天的原則變成能跑的程式碼。


上一篇
我推的Laravel S2|Day 21:Request Lifecycle:一個請求的完整旅程
下一篇
我推的Laravel S2|Day 23:Service Container 與 Service Provider:依賴注入實戰
系列文
我推的Laravel S2!24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言