PHP 5 引入異常機制,PHP 7/8 進一步強化型別系統與錯誤層級。掌握 try-catch 不僅能優雅處理錯誤,更是建構健壯、可維護應用的關鍵。本文涵蓋語法基礎、異常層級、自訂異常、全域處理、測試策略與現代最佳實踐。
<?php
try {
// 可能拋出異常的程式碼
$result = riskyOperation($input);
} catch (SpecificException $e) {
// 處理特定異常
handleSpecific($e);
} catch (AnotherException $e) {
// 處理另一類異常
handleAnother($e);
} catch (Throwable $e) {
// 兜底:捕獲所有 Exception 與 Error (PHP 7+)
logError($e);
throw $e; // 可選:重新拋出讓上層處理
} finally {
// 無論是否拋出異常都會執行(資源釋放、鎖解除、日誌完成)
cleanup();
}
Throwable (介面)
├── Exception (類別)
│ ├── LogicException
│ │ ├── InvalidArgumentException
│ │ ├── LengthException
│ │ └── DomainException
│ ├── RuntimeException
│ │ ├── OutOfBoundsException
│ │ ├── OverflowException
│ │ └── UnexpectedValueException
│ └── 自訂業務異常...
└── Error (類別)
├── TypeError
├── ValueError
├── ArithmeticError
└── ParseError (編譯錯誤,無法捕獲)
<?php
namespace App\Exceptions;
use Exception;
abstract class BusinessException extends Exception
{
public function __construct(
string $message = '',
int $code = 0,
?Throwable $previous = null,
public readonly array $context = [] // PHP 8.1+ readonly 屬性攜帶上下文
) {
parent::__construct($message, $code, $previous);
}
}
final class UserNotFoundException extends BusinessException {}
final class InsufficientBalanceException extends BusinessException {}
final class InvalidCouponException extends BusinessException {}
// 使用
throw new UserNotFoundException('用戶不存在', 404, null, ['user_id' => $id]);
<?php
namespace App\Services;
use App\Exceptions\InsufficientBalanceException;
use App\Exceptions\UserNotFoundException;
use App\Models\User;
use App\Models\Wallet;
use PDO;
class TransferService
{
public function __construct(private PDO $db) {}
public function transfer(int $fromUserId, int $toUserId, float $amount): void
{
if ($amount <= 0) {
throw new InvalidArgumentException('轉帳金額必須大於 0');
}
$this->db->beginTransaction();
try {
// 鎖定雙方錢包 (SELECT ... FOR UPDATE)
$fromWallet = $this->lockWallet($fromUserId);
$toWallet = $this->lockWallet($toUserId);
if ($fromWallet['balance'] < $amount) {
throw new InsufficientBalanceException(
'餘額不足',
400,
null,
['user_id' => $fromUserId, 'balance' => $fromWallet['balance'], 'amount' => $amount]
);
}
// 扣款/入帳
$this->db->prepare('UPDATE wallets SET balance = balance - ? WHERE user_id = ?')->execute([$amount, $fromUserId]);
$this->db->prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?')->execute([$amount, $toUserId]);
// 記錄流水
$this->recordTransaction($fromUserId, -$amount, 'transfer_out', $toUserId);
$this->recordTransaction($toUserId, $amount, 'transfer_in', $fromUserId);
$this->db->commit();
} catch (Throwable $e) {
$this->db->rollBack();
throw $e; // 重新拋出讓上層 Controller/Handler 處理
}
}
private function lockWallet(int $userId): array|false
{
$stmt = $this->db->prepare('SELECT * FROM wallets WHERE user_id = ? FOR UPDATE');
$stmt->execute([$userId]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: false;
}
private function recordTransaction(int $userId, float $amount, string $type, int $relatedId): void
{
$this->db->prepare('INSERT INTO transactions (user_id, amount, type, related_user_id) VALUES (?, ?, ?, ?)')
->execute([$userId, $amount, $type, $relatedId]);
}
}
<?php
namespace App\Http\Controllers;
use App\Exceptions\BusinessException;
use App\Services\TransferService;
use Throwable;
class TransferController
{
public function __construct(private TransferService $service) {}
public function store(): void
{
header('Content-Type: application/json; charset=utf-8');
try {
$input = json_decode(file_get_contents('php://input'), true, 512, JSON_THROW_ON_ERROR);
$this->service->transfer(
(int)($_SESSION['user_id'] ?? 0),
(int)($input['to_user_id'] ?? 0),
(float)($input['amount'] ?? 0)
);
echo json_encode(['success' => true, 'message' => '轉帳成功']);
} catch (BusinessException $e) {
http_response_code($e->getCode() ?: 400);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
'code' => $e->getCode(),
'context' => $e->context,
]);
} catch (Throwable $e) {
// 系統級錯誤:記錄完整堆疊,回傳通用訊息
error_log($e->getMessage() . '\n' . $e->getTraceAsString());
http_response_code(500);
echo json_encode(['success' => false, 'error' => '系統繁忙,請稍後再試']);
}
}
}
<?php
// bootstrap/exception_handler.php
set_exception_handler(function (Throwable $e) {
// 1. 記錄完整錯誤
error_log(
"[{$e::class}] {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}\n{$e->getTraceAsString()}"
);
// 2. 區分環境回應
if (($_ENV['APP_DEBUG'] ?? false) === true) {
// 開發環境:顯示詳細錯誤頁
header('Content-Type: text/html; charset=utf-8', true, 500);
echo "<pre>{$e->getMessage()}\n\n{$e->getTraceAsString()}</pre>";
} else {
// 生產環境:友善錯誤頁
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => '伺服器內部錯誤,請稍後再試']);
}
});
// 註冊致命錯誤處理 (PHP 7+)
register_shutdown_function(function () {
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
// 模擬 Throwable 物件
$e = new ErrorException($error['message'], 0, $error['type'], $error['file'], $error['line']);
// 重用上述邏輯...
}
});
<?php
// tests/TransferServiceTest.php (Pest/PHPUnit)
use App\Exceptions\InsufficientBalanceException;
use App\Services\TransferService;
test('餘額不足時拋出 InsufficientBalanceException', function () {
$pdo = new PDO('sqlite::memory:');
// 建立 schema、插入測試資料...
$service = new TransferService($pdo);
expect(fn() => $service->transfer(1, 2, 1000))
->toThrow(InsufficientBalanceException::class)
->and($e->context['balance'] < 1000);
});
| 原則 | 說明 |
|---|---|
| 具體捕獲 | 優先 catch 具體異常 (BusinessException),最後才 catch Throwable |
| 異常即文件 | 自訂異常類別名稱即為業務規則文件,減少註解 |
| 攜帶上下文 | PHP 8.1+ readonly 屬性附帶關鍵參數,便於日誌追蹤與前端提示 |
| finally 釋放資源 | 鎖、檔案控制代碼、資料庫連線、暫存檔必在 finally 釋放 |
| 不要吞沒異常 | catch 後若無法處理,必須 throw 或記錄後轉為適當回應 |
| 區分業務/系統錯誤 | 業務異常回 4xx + 明確訊息;系統異常記錄堆疊、回 500 + 通用訊息 |
| 單一職責 | Service 拋異常,Controller/Handler 負責轉換為 HTTP 回應 |
| 測試異常路徑 | 每個拋出異常的分支都要有對應測試案例 |
try-catch 不只是語法糖,而是領域模型與基礎設施分離的關鍵機制。善用自訂異常承載業務語意、finally 保證資源釋放、全域處理器統一錯誤回應、測試覆蓋異常路徑,能讓 PHP 應用在面對預期與非預期錯誤時,都能優雅、安全、可觀測地運行。