例外處理有點像替程式準備安全網:希望它永遠用不到,但真的出事時,總不能只對著錯誤畫面喝咖啡。這篇會用白話說明 PHP 的 trycatchfinallyThrowable 與自訂例外,讓你知道該在哪裡接住問題,也知道哪些問題根本不是同一種球。

讀完後,你應該能寫出基本的例外處理、依例外類型採取不同策略,並在資料庫操作中保留原始錯誤脈絡,而不是把所有錯誤都包成一句「好像哪裡怪怪的」。

先分清楚:例外、錯誤與 Throwable

PHP 中可以被 throw 拋出、再由 catch 接住的物件,必須實作 Throwable 介面。內建的 ExceptionError 都屬於這個家族,但兩者的繼承關係不同:

  • Exception 通常代表應用程式或函式庫預期可以處理的例外。
  • Error 代表 PHP 執行期間發現的錯誤,例如部分型別或算術錯誤。
  • Throwable 是兩者共同的上層介面。

因此,catch (Exception $e) 不會接住 Error。如果你確實需要同時處理兩者,可以捕獲 Throwable;不過,這不代表所有錯誤都適合被吃掉。語法錯誤、設定問題或程式設計錯誤,通常應該在開發與記錄階段被找出,而不是在生產環境偷偷假裝沒事。

trycatchfinally 怎麼合作?

這三個關鍵字各自負責一件事:

  • try:放入可能拋出例外的程式碼。
  • catch:接住符合指定類型的 Throwable,並決定如何回應。
  • finally:不論是否發生例外,都在流程離開 trycatch 前執行清理工作。

一個最小但完整的例子如下:

<?php

function divide(int $dividend, int $divisor): float
{
	if ($divisor === 0) {
		throw new InvalidArgumentException('除數不能是 0。');
	}

	return $dividend / $divisor;
}

try {
	echo divide(10, 2);
} catch (InvalidArgumentException $exception) {
	echo '輸入無效:' . $exception->getMessage();
} finally {
	echo PHP_EOL . '這段清理流程一定會執行。';
}

throw 之後,同一個區塊中尚未執行的程式碼會被跳過,PHP 會往外尋找第一個符合類型的 catch。如果一路找不到,例外就會繼續往呼叫堆疊上層傳遞,最後可能成為未捕獲例外。

另外,finally 不是「錯誤發生時才執行」的區塊;成功與失敗都會走到它。它很適合放關閉檔案、釋放鎖定或還原暫存狀態等清理工作。清理區塊也不要再次拋出無關例外,否則可能覆蓋原本更重要的錯誤——錯誤會排隊,但不保證乖乖排隊。

檔案操作不一定會自動拋出例外

這是初學者很容易踩到的坑:並非所有 PHP 函式失敗時都會拋出 Exception。例如 fopen() 找不到檔案時,通常會回傳 false 並產生警告;單純把它放進 try,不會自動變成可捕獲的例外。

如果你的程式需要用例外流程處理這種失敗,就要明確檢查回傳值,再自行拋出例外:

<?php

function openFile(string $path)
{
	$handle = fopen($path, 'r');

	if ($handle === false) {
		throw new RuntimeException('無法開啟檔案:' . $path);
	}

	return $handle;
}

$handle = null;

try {
	$handle = openFile(__DIR__ . '/example.txt');
	$content = stream_get_contents($handle);
} catch (RuntimeException $exception) {
	echo '檔案處理失敗:' . $exception->getMessage();
} finally {
	if (is_resource($handle)) {
		fclose($handle);
	}
}

這裡的重點不是把每個警告都硬轉成例外,而是先確認你使用的 API 如何回報失敗,再選擇一致的處理方式。

建立自訂例外

當程式只丟出一個通用的 Exception,呼叫端往往只能看訊息猜發生什麼事。建立有語意的例外類別,可以讓 catch 更精準,也讓日後搜尋記錄檔容易很多。

自訂例外通常只要繼承 Exception 或它的子類別即可:

<?php

class FileNotFoundException extends RuntimeException
{
}

function readConfig(string $path): string
{
	if (!is_file($path)) {
		throw new FileNotFoundException('找不到設定檔:' . $path);
	}

	$config = file_get_contents($path);

	if ($config === false) {
		throw new RuntimeException('無法讀取設定檔:' . $path);
	}

	return $config;
}

try {
	$config = readConfig(__DIR__ . '/config.php');
} catch (FileNotFoundException $exception) {
	echo '設定檔不存在,請檢查部署內容。';
}

類別名稱本身就是文件。與其在每個地方解析一長串錯誤訊息,不如讓 FileNotFoundException 直接說明「是哪一類問題」。

如果需要保留較低層的原因,可以在建立新例外時傳入上一個例外。這種做法稱為例外鏈結:

<?php

class DatabaseException extends RuntimeException
{
}

try {
	// 這裡假設資料庫函式庫拋出了 PDOException。
	throw new PDOException('連線被拒絕');
} catch (PDOException $exception) {
	throw new DatabaseException('無法完成資料庫操作。', 0, $exception);
}

上層可以用 getMessage() 取得對使用者較安全的訊息,也可以用 getPrevious() 追查原始原因。請避免直接把資料庫帳號、密碼、完整 SQL 或堆疊資訊顯示給使用者;這些內容比較適合寫入受保護的伺服器記錄。

多個 catch:順序就是優先權

一個 try 後面可以接多個 catch。PHP 會從上到下比對,執行第一個符合的區塊。因此,具體的例外要放前面,較一般的類型放後面:

<?php

try {
	// 可能拋出不同類型的 Throwable。
} catch (FileNotFoundException $exception) {
	echo '找不到檔案。';
} catch (RuntimeException $exception) {
	echo '執行時發生可預期的問題。';
} catch (Throwable $exception) {
	echo '發生未預期的錯誤。';
}

反過來,如果先寫 catch (Throwable $exception),後面的區塊就永遠沒有機會接手。這就像先在門口安排一個「所有人都進來」的保全,後面再安排「只接待 PHP 開發者」的櫃檯,後者自然會失業。

若不同例外需要完全相同的處理方式,也可以使用聯合捕獲語法:

<?php

try {
	// 可能拋出這兩類例外。
} catch (InvalidArgumentException | UnexpectedValueException $exception) {
	echo '收到無效的資料。';
}

finally 的實務用法

finally 適合處理「無論成功或失敗都要做」的事情,例如關閉檔案、釋放鎖或還原狀態。它不適合拿來掩蓋錯誤,也不建議在裡面寫 return,因為那可能覆蓋 trycatch 原本要回傳的值,讓除錯難度直接升級。

需要注意的是,finally 不是萬能清潔工。若程式直接結束,或清理本身拋出新的例外,流程可能不會照你預想的方式完成。因此,清理程式應該保持簡單、可預期。

實用案例:包裝 PDO 例外

以下用新增書籍的流程示範例外分層。範例明確設定 PDO 使用例外模式,避免把不同的錯誤回報方式混在一起:

<?php

class DatabaseException extends RuntimeException
{
}

function addBook(PDO $db, string $title, string $author): void
{
	try {
		$statement = $db->prepare(
			'INSERT INTO books (title, author) VALUES (:title, :author)'
		);
		$statement->execute([
			'title' => $title,
			'author' => $author,
		]);
	} catch (PDOException $exception) {
		throw new DatabaseException('新增書籍失敗。', 0, $exception);
	}
}

try {
	$db = new PDO(
		'mysql:host=localhost;dbname=library;charset=utf8mb4',
		$username,
		$password,
		[
			PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
		]
	);

	addBook($db, '1984', 'George Orwell');
	echo '書籍新增成功。';
} catch (DatabaseException $exception) {
	echo '目前無法新增書籍,請稍後再試。';
	// 實務上可在這裡記錄 $exception 與 getPrevious() 的資訊。
}

這個範例有三個值得保留的設計:

  1. addBook() 將底層的 PDOException 包裝成應用程式看得懂的 DatabaseException
  2. throw new DatabaseException(..., 0, $exception) 保留原始例外,方便記錄與除錯。
  3. 對外只顯示安全、友善的訊息,不把資料庫細節直接送到瀏覽器。

資料庫連線的生命週期也不必硬塞進 finally。當 $db 離開作用域且沒有其他參照時,PHP 會回收物件;真正需要 finally 的情境,應該是你有明確的資源或狀態需要無條件清理。

常見問題

catch (Exception $exception) 能接住所有錯誤嗎?

不能。Error 不繼承 Exception。若需要同時接住實作 ThrowableExceptionError,可以使用 catch (Throwable $exception),但應先確認這樣做符合你的錯誤處理策略。

所有 PHP 函式失敗時都會拋出例外嗎?

不會。不同函式的行為不同,有些會回傳 falsenull 或錯誤碼,有些才會拋出例外。請先查閱該函式的文件,再決定是否需要檢查回傳值或設定錯誤模式。

finally 一定會執行嗎?

在正常離開 trycatch 的流程中,finally 會執行,包括 trycatch 中遇到 return 的情況。但若程式直接終止,或 finally 自己拋出例外,就可能改變原本的流程與結果。

什麼時候該捕獲 Throwable

在應用程式邊界、記錄未預期錯誤或建立統一錯誤回應時,Throwable 可能有用。一般業務邏輯則應優先捕獲具體類型,否則很容易把真正的程式錯誤當成正常流程處理。

總結

PHP 例外處理的核心不是「把所有錯誤抓起來」,而是讓錯誤在合適的邊界被辨識、記錄與回應。先分清楚 ExceptionErrorThrowable,再用具體的自訂例外表達意圖;需要清理時使用 finally,需要跨層轉換時保留 getPrevious() 的原始脈絡。

當你開始這樣設計,錯誤就不再只是紅色畫面,而會變成可以追蹤、可以處理的訊號。咖啡可以繼續喝,例外則交給正確的 catch

參考資料

  1. PHP 官方文件:Exceptions:說明 trycatchfinally、例外傳遞與 Throwable
  2. PHP 官方文件:Errors in PHP 7:說明 ErrorException 的差異,以及 Throwable 的角色。
  3. PHP 官方文件:PDO — Errors and error handling:說明 PDO 的錯誤模式與 PDOException