iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Software Development

一套真實運作中的 Laravel 系統,拆解它的原生機制系列 第 15

Day 15:Observer 兩種註冊方式對照——PHP 8 Attribute vs ServiceProvider

  • 分享至 

  • xImage
  •  

前言:同一個系統裡,Observer 為什麼有兩種不同的註冊寫法?

「Eloquent Observer 不就是在 ServiceProvider 裡 Model::observe(SomeObserver::class) 一行搞定嗎?」

這是多數 Laravel 開發者對 Observer 註冊的第一印象——確實,這是最常見、文件範例裡最常出現的寫法。但今天要看的這個真實系統裡,同時存在兩種不同的 Observer 註冊方式,一個 Model 用了新式的 PHP 8 Attribute 語法,另一個 Model 用了傳統的 ServiceProvider 手動註冊。這不是誰對誰錯的問題,而是一個好機會,具體比較兩種寫法的差異跟各自適合的情境。

今日目標

  • 認識 PHP 8 Attribute 語法怎麼註冊 Eloquent Observer
  • 對照傳統 ServiceProvider 手動註冊的寫法
  • 看一個 Observer 裡的防禦性設計:try/catch 包住內容轉換邏輯的理由
  • 建立「什麼情境該用哪種註冊方式」的判斷依據

本文主體

寫法一:PHP 8 Attribute(#[ObservedBy]

系統裡負責新聞內容的 Model,用的是這種寫法:

#[ObservedBy(NewsObserve::class)]
class News extends Model
{
    // ...
}

註冊邏輯直接寫在 Model 類別本身的上方,作為一個 Attribute。好處很直接:看到 Model 就知道它掛了哪個 Observer,不用另外跑去 ServiceProvider 翻找。這是 Laravel 較新版本才支援的語法,把「這個 Model 有 Observer」這件事,從「藏在別的檔案裡的設定」變成「寫在 Model 自己身上的宣告」。

寫法二:傳統 ServiceProvider 手動註冊

負責媒體檔案的 Model,用的是比較傳統的寫法,在 AppServiceProvider::boot() 裡:

Media::observe(MediaObserver::class);

這種寫法的資訊分散在兩個地方——要知道「Media 這個 Model 有沒有掛 Observer」,得先想到要去 ServiceProvider 裡找。但它也有自己的優勢:所有 Observer 的註冊集中在同一個地方,一眼就能看到整個系統掛了哪些 Observer,不用一個一個 Model 翻過去確認。

兩種寫法沒有絕對的優劣,是不同的資訊組織方式

Attribute 語法讓資訊「就近可見」(打開 Model 就看到),ServiceProvider 集中註冊讓資訊「集中可見」(打開一個檔案看到全部)。這個系統裡兩種寫法並存,一個合理的解讀是:不同時期加進來的功能、或者不同開發者的習慣,並不代表故意設計成這樣的混合策略。這正是真實系統跟教學範例的差異之一——教學範例會示範一種「標準寫法」,真實系統裡往往是歷史演進留下的多種寫法並存。

如果要從中挑一個原則:一個新加入的 Model 如果只有一兩個 Observer,用 Attribute 語法直接寫在 Model 上,維護時比較不容易忘記它的存在;如果系統裡 Observer 數量開始變多,需要有一個總覽的視角,集中在 ServiceProvider 裡註冊會比較好管理。

Observer 裡的防禦性設計:為什麼要包一層 try/catch

負責新聞內容的 Observer,saving() 方法裡做的事情是:解析內容裡的圖片標籤,把外部系統回傳的圖片路徑格式,轉換成站內可以直接下載的公開網址。整段邏輯包在 try/catch 裡:

public function saving(News $news): void
{
    try {
        // 解析內容裡的圖片標籤,轉換路徑格式
        // ...
    } catch (Throwable $e) {
        report($e);
    }
}

這裡的設計選擇值得停下來想:內容轉換失敗,不會讓整筆資料的存檔動作跟著失敗——catch 住例外之後只是回報錯誤,讓存檔繼續進行,只是這次的圖片路徑轉換沒有生效。

這是一個明確的權衡:存檔失敗的代價(使用者的編輯內容整個遺失)遠大於「圖片路徑轉換這次沒生效」的代價(頂多是這篇內容的某張圖顯示不出來,之後還可以再修)。如果反過來讓例外往外拋,一次意料之外的內容格式,就可能讓編輯者辛苦寫好的內容整個存不進去——這種「因為一個邊角情況讓核心操作全部失敗」的設計,通常不是使用者想要的行為。

對照:讓例外往外拋 vs 吞下例外讓核心動作繼續

內容解析失敗,直接讓整個存檔失敗

public function saving(News $news): void
{
    $crawler = new Crawler($news->content);
    // 如果 $news->content 格式跑掉,這裡直接拋例外,
    // 整筆存檔動作被中斷,使用者剛編輯好的內容全部遺失
}

核心動作(存檔)優先,附加邏輯失敗不影響核心

public function saving(News $news): void
{
    try {
        // 內容解析與轉換
    } catch (Throwable $e) {
        report($e);
        // 存檔繼續,只是這次的圖片路徑轉換沒生效
    }
}

這個判斷不是通用真理——如果 Observer 裡做的是「驗證資料合法性」這種本來就該擋下不合法存檔的邏輯,讓例外往外拋才是對的。關鍵在於分清楚:這段邏輯是「核心業務規則」還是「附加的加值處理」,兩者對失敗的容忍度應該不一樣。

今日思考題

你的專案裡有沒有 Observer 或類似的 hook 邏輯,裡面做的事情失敗時,會不會連累到不該被連累的核心操作?回想一下,那段邏輯的失敗容忍度,是你刻意設計的,還是寫的時候沒有特別想過?

今日重點回顧

  • PHP 8 Attribute(#[ObservedBy])讓 Observer 註冊資訊就近寫在 Model 上,方便打開 Model 就看到
  • 傳統 ServiceProvider 手動註冊讓所有 Observer 集中在同一處,方便總覽整個系統掛了哪些 Observer
  • 兩種寫法在同一個系統裡並存,通常反映的是歷史演進而不是刻意的混合策略
  • Observer 裡用 try/catch 包住附加邏輯,是「核心動作優先於附加處理」的明確權衡,不是無腦加上去的防禦寫法

明日預告

明天要看一個靠 Laravel 事件自動探索機制、完全沒有手動註冊的 Listener——一筆登入事件,怎麼在沒有人明確接線的情況下,自動變成一筆審計紀錄。


上一篇
Day 14:一支 Job 該做多少事?從 16 行 vs 120 行的 Job 看職責邊界
下一篇
Day 16:Event/Listener——登入事件怎麼靠自動探索變成一筆審計紀錄
系列文
一套真實運作中的 Laravel 系統,拆解它的原生機制20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言