はじめに
PHPで try-catch を使っても、捕捉し忘れた例外やコード全体を包むことが難しい例外が発生した場合、デフォルトではエラーメッセージがそのままブラウザに表示されます。本番環境でスタックトレースや内部構造が露出することは、セキュリティ上の重大なリスクです。
set_exception_handler() は、try-catch で捕捉されなかった例外(未捕捉例外)を最後に受け取るグローバルハンドラを登録する関数です。ここでユーザーへの安全なエラー画面表示・詳細なログ記録・開発者への通知をまとめて処理することで、例外が「握りつぶされる」ことも「そのまま露出する」こともない堅牢なアプリケーションを構築できます。
関数の概要
| 項目 | 内容 |
|---|---|
| 関数名 | set_exception_handler() |
| 所属 | PHP エラー処理関数 |
| 導入バージョン | PHP 5.0以降 |
| PHP 8.x | 対応済み |
構文
set_exception_handler(?callable $callback): callable|null
パラメータ
| パラメータ | 型 | 説明 |
|---|---|---|
$callback | callable|null | 未捕捉例外を受け取るハンドラ。null を渡すとデフォルトに戻す |
戻り値
- 以前に設定されていたハンドラ(callable)を返します
- 以前のハンドラがない場合は
nullを返します
コールバック関数のシグネチャ
function exceptionHandler(\Throwable $exception): void
| パラメータ | 型 | 説明 |
|---|---|---|
$exception | Throwable | 捕捉されなかった例外・エラーオブジェクト |
重要:PHP 7以降、
ExceptionだけでなくError(TypeError・ParseErrorなど)もThrowableを実装しているため、コールバックの型ヒントは\Throwableを使うことを推奨します。
⚠️ 注意:ハンドラが呼ばれた後、PHPスクリプトの実行は終了します。ハンドラ内で処理を継続したり、通常のレスポンスを返したりすることはできません。
set_exception_handler() が呼ばれるタイミング
throw new Exception('...') が実行される
↓
catch ブロックで捕捉されるか?
YES → catch 内で処理される(set_exception_handler は関係しない)
NO → set_exception_handler() に登録したハンドラが呼ばれる
↓
ハンドラの処理が終わるとスクリプト終了
Exception と Error の継承ツリー(PHP 7以降)
Throwable(interface)
├── Exception
│ ├── RuntimeException
│ ├── InvalidArgumentException
│ ├── LogicException
│ └── ...
└── Error
├── TypeError
├── ParseError
├── ArithmeticError
├── AssertionError
└── ...
set_exception_handler() は Throwable を実装するすべてのクラスを受け取れます。
基本的な使い方
<?php
set_exception_handler(function (\Throwable $e): void {
echo "未捕捉例外: [{$e->getCode()}] {$e->getMessage()}\n";
echo "発生場所: {$e->getFile()}:{$e->getLine()}\n";
exit(1);
});
// try-catch なしで例外を throw
throw new \RuntimeException('捕捉されない例外');
/*
出力例:
未捕捉例外: [0] 捕捉されない例外
発生場所: /path/to/script.php:9
*/
実践的なクラスベースの使用例
例1:本番・開発環境対応の例外ハンドラクラス
<?php
/**
* 環境に応じてエラー表示を切り替える例外ハンドラクラス
* 本番:ユーザーに安全なエラー画面を表示・詳細はログへ
* 開発:スタックトレースをすべて表示してデバッグを支援
*/
class EnvironmentAwareExceptionHandler
{
private string $environment;
private string $logFile;
public function __construct(
string $environment = 'production',
string $logFile = '/var/log/app/exceptions.log'
) {
$this->environment = $environment;
$this->logFile = $logFile;
}
public function register(): ?callable
{
return set_exception_handler([$this, 'handle']);
}
public function handle(\Throwable $e): void
{
// 常にログに詳細を記録する
$this->log($e);
if ($this->environment === 'production') {
$this->renderProductionError($e);
} else {
$this->renderDevelopmentError($e);
}
exit(1);
}
private function renderProductionError(\Throwable $e): void
{
// ユーザーには内部情報を一切見せない
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/html; charset=UTF-8');
}
echo "<!DOCTYPE html><html><body>\n";
echo "<h1>申し訳ございません</h1>\n";
echo "<p>予期しないエラーが発生しました。しばらくしてから再度お試しください。</p>\n";
echo "</body></html>\n";
}
private function renderDevelopmentError(\Throwable $e): void
{
// 開発環境ではスタックトレースを表示
$class = get_class($e);
echo "\n🚨 未捕捉例外\n";
echo str_repeat('=', 60) . "\n";
printf("クラス : %s\n", $class);
printf("メッセージ: %s\n", $e->getMessage());
printf("コード : %d\n", $e->getCode());
printf("ファイル: %s:%d\n", $e->getFile(), $e->getLine());
echo "\nスタックトレース:\n";
echo $e->getTraceAsString() . "\n";
if ($e->getPrevious()) {
echo "\n原因となった例外:\n";
printf(" %s: %s\n", get_class($e->getPrevious()), $e->getPrevious()->getMessage());
}
}
private function log(\Throwable $e): void
{
$entry = sprintf(
"[%s] %s: %s in %s:%d\nTrace:\n%s\n\n",
date('Y-m-d H:i:s'),
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString()
);
$dir = dirname($this->logFile);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($this->logFile, $entry, FILE_APPEND | LOCK_EX);
echo "[ログ記録] " . get_class($e) . ": {$e->getMessage()}\n";
}
}
$handler = new EnvironmentAwareExceptionHandler(environment: 'development');
$handler->register();
// throw new \RuntimeException('テスト例外');
例2:例外の種類別ハンドリングクラス
<?php
/**
* 例外クラスの種類に応じて異なる処理を行うハンドラクラス
* HTTPステータスコードのマッピング・エラーページの切り替えなど
*/
class TypedExceptionHandler
{
/** 例外クラス → HTTPステータスコードのマッピング */
private array $statusMap = [
\InvalidArgumentException::class => 400,
\RuntimeException::class => 500,
\LogicException::class => 500,
\OverflowException::class => 429,
\UnexpectedValueException::class => 422,
];
/** 例外クラス → ユーザー向けメッセージのマッピング */
private array $messageMap = [
\InvalidArgumentException::class => 'リクエストの内容が不正です',
\OverflowException::class => 'リクエストが多すぎます。しばらくお待ちください',
\UnexpectedValueException::class => '送信されたデータを処理できません',
];
public function register(): ?callable
{
return set_exception_handler([$this, 'handle']);
}
public function handle(\Throwable $e): void
{
$statusCode = $this->resolveStatusCode($e);
$userMessage = $this->resolveUserMessage($e);
$this->sendResponse($statusCode, $userMessage, $e);
exit(1);
}
private function resolveStatusCode(\Throwable $e): int
{
foreach ($this->statusMap as $class => $code) {
if ($e instanceof $class) {
return $code;
}
}
return 500;
}
private function resolveUserMessage(\Throwable $e): string
{
foreach ($this->messageMap as $class => $msg) {
if ($e instanceof $class) {
return $msg;
}
}
return '予期しないエラーが発生しました';
}
private function sendResponse(int $statusCode, string $message, \Throwable $e): void
{
if (!headers_sent()) {
http_response_code($statusCode);
}
$isApiRequest = str_contains(
$_SERVER['HTTP_ACCEPT'] ?? '',
'application/json'
);
if ($isApiRequest) {
header('Content-Type: application/json; charset=UTF-8');
echo json_encode([
'error' => $message,
'status' => $statusCode,
], JSON_UNESCAPED_UNICODE);
} else {
echo "<p>[{$statusCode}] {$message}</p>\n";
}
echo "[{$statusCode}] " . get_class($e) . ": {$e->getMessage()}\n";
}
/**
* 例外クラスとステータスコードのマッピングを追加する
*/
public function addMapping(string $exceptionClass, int $statusCode, string $userMessage = ''): void
{
$this->statusMap[$exceptionClass] = $statusCode;
if ($userMessage !== '') {
$this->messageMap[$exceptionClass] = $userMessage;
}
}
}
$handler = new TypedExceptionHandler();
$handler->addMapping(\DomainException::class, 400, 'ドメインルール違反です');
$handler->register();
// throw new \InvalidArgumentException('不正なパラメータ');
// throw new \DomainException('ドメイン違反の操作');
例3:例外チェーンを追跡するロギングハンドラクラス
<?php
/**
* 例外チェーン(getPrevious)を再帰的に追跡してすべての原因を記録するクラス
* 根本原因(root cause)の特定を容易にする
*/
class ChainedExceptionLogger
{
private array $records = [];
public function register(): ?callable
{
return set_exception_handler([$this, 'handle']);
}
public function handle(\Throwable $e): void
{
$chain = $this->buildChain($e);
$this->printChain($chain);
$this->saveToLog($chain);
exit(1);
}
/**
* 例外チェーンをフラットな配列に展開する
*/
private function buildChain(\Throwable $e): array
{
$chain = [];
$current = $e;
$depth = 0;
while ($current !== null) {
$chain[] = [
'depth' => $depth,
'class' => get_class($current),
'message' => $current->getMessage(),
'code' => $current->getCode(),
'file' => $current->getFile(),
'line' => $current->getLine(),
'trace' => $current->getTraceAsString(),
];
$current = $current->getPrevious();
$depth++;
}
return $chain;
}
private function printChain(array $chain): void
{
$total = count($chain);
echo "\n🔗 例外チェーン({$total}件)\n";
echo str_repeat('─', 50) . "\n";
foreach ($chain as $i => $entry) {
$isRoot = ($i === $total - 1);
$prefix = $isRoot ? "🔴 根本原因" : " #" . ($i + 1);
printf(
"%s [%s] %s\n 場所: %s:%d\n",
$prefix,
$entry['class'],
$entry['message'],
basename($entry['file']),
$entry['line']
);
if ($i < $total - 1) {
echo " ↑ caused by\n";
}
}
}
private function saveToLog(array $chain): void
{
$this->records[] = [
'timestamp' => date('Y-m-d H:i:s'),
'chain' => $chain,
];
// 実際にはファイルや DB に保存する
echo "\n[ログ保存] チェーン深さ: " . count($chain) . "件\n";
}
public function getRecords(): array
{
return $this->records;
}
}
// 利用例
$logger = new ChainedExceptionLogger();
$logger->register();
/*
throw new \RuntimeException(
'注文処理に失敗しました',
0,
new \InvalidArgumentException(
'在庫不足',
0,
new \OverflowException('在庫数が負になります')
)
);
*/
/*
出力例:
🔗 例外チェーン(3件)
──────────────────────────────────────────────────
#1 [RuntimeException] 注文処理に失敗しました
場所: script.php:XX
↑ caused by
#2 [InvalidArgumentException] 在庫不足
場所: script.php:XX
↑ caused by
🔴 根本原因 [OverflowException] 在庫数が負になります
場所: script.php:XX
*/
例4:ハンドラの優先度付きチェーンクラス
<?php
/**
* 複数のハンドラを優先度付きで登録し、
* 条件に一致する最初のハンドラに処理を委譲するクラス
* set_exception_handler() は一つしか登録できないが、
* このクラスで擬似的な複数ハンドラを実現する
*/
class PrioritizedExceptionHandlerChain
{
private array $handlers = [];
/**
* ハンドラを優先度付きで追加する(数値が小さいほど先に試行)
*
* @param callable(Throwable): bool $matcher このハンドラが処理すべきかを判断する
* @param callable(Throwable): void $handler 実際の処理
*/
public function add(callable $matcher, callable $handler, int $priority = 50): void
{
$this->handlers[] = [
'matcher' => $matcher,
'handler' => $handler,
'priority' => $priority,
];
usort($this->handlers, fn($a, $b) => $a['priority'] <=> $b['priority']);
}
/**
* このチェーンを set_exception_handler() に登録する
*/
public function register(): ?callable
{
return set_exception_handler([$this, 'dispatch']);
}
public function dispatch(\Throwable $e): void
{
foreach ($this->handlers as $entry) {
if (($entry['matcher'])($e)) {
($entry['handler'])($e);
exit(1);
}
}
// どのハンドラにも一致しない場合のフォールバック
echo "🚨 [フォールバック] " . get_class($e) . ": {$e->getMessage()}\n";
exit(1);
}
}
$chain = new PrioritizedExceptionHandlerChain();
// 優先度10:認証エラー
$chain->add(
matcher: fn(\Throwable $e) => $e instanceof \RuntimeException
&& str_contains($e->getMessage(), '認証'),
handler: function (\Throwable $e): void {
echo "🔐 認証エラー: {$e->getMessage()}\n";
// header('Location: /login');
},
priority: 10
);
// 優先度20:バリデーションエラー
$chain->add(
matcher: fn(\Throwable $e) => $e instanceof \InvalidArgumentException,
handler: function (\Throwable $e): void {
echo "📋 バリデーションエラー: {$e->getMessage()}\n";
},
priority: 20
);
// 優先度99:汎用エラー(最後に受け取る)
$chain->add(
matcher: fn(\Throwable $e) => true, // 常にマッチ
handler: function (\Throwable $e): void {
echo "💥 汎用エラー: [" . get_class($e) . "] {$e->getMessage()}\n";
},
priority: 99
);
$chain->register();
throw new \InvalidArgumentException('メールアドレスの形式が不正です');
/*
出力例:
📋 バリデーションエラー: メールアドレスの形式が不正です
*/
例5:JSON API 向け例外レスポンスクラス
<?php
/**
* REST API 向けに例外を JSON レスポンスに変換するハンドラクラス
* HTTP ステータスコード・エラーコード・メッセージを構造化して返す
*/
class ApiExceptionHandler
{
/** 例外クラス → [HTTPステータス, エラーコード] */
private array $mapping = [
\InvalidArgumentException::class => [400, 'INVALID_ARGUMENT'],
\UnexpectedValueException::class => [422, 'UNPROCESSABLE'],
\OverflowException::class => [429, 'RATE_LIMITED'],
\OutOfRangeException::class => [404, 'NOT_FOUND'],
\BadMethodCallException::class => [405, 'METHOD_NOT_ALLOWED'],
\DomainException::class => [400, 'DOMAIN_ERROR'],
\RuntimeException::class => [500, 'INTERNAL_ERROR'],
];
private bool $debug;
public function __construct(bool $debug = false)
{
$this->debug = $debug;
}
public function register(): ?callable
{
return set_exception_handler([$this, 'handle']);
}
public function handle(\Throwable $e): void
{
[$statusCode, $errorCode] = $this->resolveMapping($e);
$payload = [
'success' => false,
'error' => [
'code' => $errorCode,
'message' => $this->safeMessage($e, $statusCode),
],
'request_id' => $this->generateRequestId(),
];
// デバッグモードでは詳細情報を付加する
if ($this->debug) {
$payload['debug'] = [
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => explode("\n", $e->getTraceAsString()),
];
}
if (!headers_sent()) {
http_response_code($statusCode);
header('Content-Type: application/json; charset=UTF-8');
header('X-Request-Id: ' . $payload['request_id']);
}
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit(1);
}
private function resolveMapping(\Throwable $e): array
{
foreach ($this->mapping as $class => [$status, $code]) {
if ($e instanceof $class) {
return [$status, $code];
}
}
return [500, 'UNKNOWN_ERROR'];
}
private function safeMessage(\Throwable $e, int $statusCode): string
{
// 5xx エラーは内部詳細を隠す(デバッグモード以外)
if ($statusCode >= 500 && !$this->debug) {
return '内部サーバーエラーが発生しました';
}
return $e->getMessage();
}
private function generateRequestId(): string
{
return sprintf('%s-%s', date('Ymd-His'), substr(bin2hex(random_bytes(4)), 0, 8));
}
}
$handler = new ApiExceptionHandler(debug: true);
$handler->register();
// throw new \InvalidArgumentException('user_id は正の整数である必要があります');
/*
出力例:
{
"success": false,
"error": {
"code": "INVALID_ARGUMENT",
"message": "user_id は正の整数である必要があります"
},
"request_id": "20250901-120000-a3f8bc12",
"debug": {
"exception": "InvalidArgumentException",
"message": "user_id は正の整数である必要があります",
...
}
}
*/
例6:set_error_handler() と組み合わせた包括的エラー処理クラス
<?php
/**
* set_exception_handler() / set_error_handler() /
* register_shutdown_function() を一元管理する包括的ハンドラクラス
*
* あらゆる種類のエラー・例外・致命的エラーを漏れなく処理する
*/
class GlobalErrorManager
{
private array $log = [];
private bool $registered = false;
private string $environment;
public function __construct(string $environment = 'production')
{
$this->environment = $environment;
}
/**
* すべてのハンドラを一括登録する
*/
public function registerAll(): void
{
if ($this->registered) {
return;
}
// 1. 未捕捉例外ハンドラ
set_exception_handler([$this, 'handleException']);
// 2. 通常エラーハンドラ(Warning / Notice など)
set_error_handler([$this, 'handleError']);
// 3. 致命的エラーハンドラ(シャットダウン時)
register_shutdown_function([$this, 'handleShutdown']);
$this->registered = true;
echo "✅ GlobalErrorManager: すべてのハンドラを登録しました\n";
}
/**
* 未捕捉例外ハンドラ
*/
public function handleException(\Throwable $e): void
{
$this->record('exception', get_class($e), $e->getMessage(), $e->getFile(), $e->getLine());
$this->respond(500, $e);
exit(1);
}
/**
* 通常エラーハンドラ
*/
public function handleError(int $errno, string $errstr, string $errfile, int $errline): bool
{
if (!(error_reporting() & $errno)) {
return false;
}
$levelName = $this->errorLevelName($errno);
$this->record('error', $levelName, $errstr, $errfile, $errline);
// E_USER_ERROR は致命的なので終了
if ($errno === E_USER_ERROR) {
$this->respond(500);
exit(1);
}
return true; // 軽微なエラーは継続
}
/**
* シャットダウン時の致命的エラーハンドラ
*/
public function handleShutdown(): void
{
$error = error_get_last();
$fatalLevels = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR;
if ($error !== null && ($error['type'] & $fatalLevels)) {
$this->record('fatal', 'FATAL', $error['message'], $error['file'], $error['line']);
$this->respond(500);
}
$this->printSummary();
}
private function record(
string $kind, string $type, string $message, string $file, int $line
): void {
$entry = compact('kind', 'type', 'message', 'file', 'line') + ['time' => date('H:i:s')];
$this->log[] = $entry;
$icon = match ($kind) {
'exception' => '🚨',
'error' => '⚠️',
'fatal' => '💀',
default => '❓',
};
printf("%s [%s] %s in %s:%d\n", $icon, $type, $message, basename($file), $line);
}
private function respond(int $statusCode, ?\Throwable $e = null): void
{
if (!headers_sent()) {
http_response_code($statusCode);
}
if ($this->environment === 'development' && $e !== null) {
echo "\n[開発環境] " . get_class($e) . ": {$e->getMessage()}\n";
echo $e->getTraceAsString() . "\n";
}
}
private function printSummary(): void
{
if (empty($this->log)) return;
echo "\n=== エラーサマリー: " . count($this->log) . "件 ===\n";
foreach ($this->log as $entry) {
printf(
" [%s] %s: %s\n",
$entry['time'], $entry['type'], $entry['message']
);
}
}
private function errorLevelName(int $errno): string
{
return [
E_WARNING => 'WARNING',
E_NOTICE => 'NOTICE',
E_USER_ERROR => 'USER_ERROR',
E_USER_WARNING => 'USER_WARNING',
E_USER_NOTICE => 'USER_NOTICE',
E_DEPRECATED => 'DEPRECATED',
E_USER_DEPRECATED => 'USER_DEPRECATED',
][$errno] ?? "E_{$errno}";
}
}
$manager = new GlobalErrorManager(environment: 'development');
$manager->registerAll();
trigger_error('設定ファイルが見つかりません', E_USER_WARNING);
// throw new \RuntimeException('データベース接続失敗');
/*
出力例:
✅ GlobalErrorManager: すべてのハンドラを登録しました
⚠️ [USER_WARNING] 設定ファイルが見つかりません in script.php:XX
=== エラーサマリー: 1件 ===
[12:00:00] USER_WARNING: 設定ファイルが見つかりません
*/
set_exception_handler() 呼び出し後の挙動
<?php
// ハンドラ呼び出し後にスクリプトは終了する
set_exception_handler(function (\Throwable $e): void {
echo "ハンドラ実行: {$e->getMessage()}\n";
// ここで exit() しなくてもスクリプトは終了する
});
throw new \Exception('テスト');
echo "この行は実行されない\n"; // ← 到達しない
restore_exception_handler() による復元
<?php
$previous = set_exception_handler(function (\Throwable $e): void {
echo "カスタムハンドラ: {$e->getMessage()}\n";
});
// ... 処理 ...
// 直前のハンドラに戻す
restore_exception_handler();
// または set_exception_handler(null) でデフォルトに戻す
関連関数との比較
| 関数 | 対象 | 役割 |
|---|---|---|
set_exception_handler() | 未捕捉の Throwable(例外・エラー) | グローバル例外ハンドラを登録 |
set_error_handler() | Warning / Notice / Deprecated など | PHPエラーのカスタムハンドラを登録 |
register_shutdown_function() | スクリプト終了時 | 致命的エラー(E_ERROR 等)の後処理に使用 |
restore_exception_handler() | ― | 直前のハンドラに戻す |
error_get_last() | ― | shutdown 内で最後のエラー情報を取得 |
よくある落とし穴
<?php
// ❌ よくある誤解:ハンドラ内で処理を「継続」できると思っている
set_exception_handler(function (\Throwable $e): void {
echo "エラーをキャッチしました\n";
// ここで return しても処理は継続しない
});
throw new \Exception('テスト');
echo "ここには到達しない\n"; // ← 実行されない
// ✅ 処理を継続したいなら try-catch を使う
try {
throw new \Exception('テスト');
} catch (\Exception $e) {
echo "catch で処理: {$e->getMessage()}\n";
}
echo "ここには到達する\n"; // ← 実行される
<?php
// ❌ 注意:PHP 5 以前は Exception しか受け取れなかった
// PHP 7以降は Error も Throwable として受け取れる
set_exception_handler(function (\Exception $e): void { // PHP 7以降はこれだと Error を受け取れない
echo $e->getMessage();
});
// ✅ PHP 7以降は Throwable を使う
set_exception_handler(function (\Throwable $e): void { // Exception も Error も受け取れる
echo $e->getMessage();
});
<?php
// ❌ NG:ハンドラ内で別の例外を throw するとハンドラが再帰的に呼ばれない
// → PHP が致命的エラーとして処理する
set_exception_handler(function (\Throwable $e): void {
throw new \RuntimeException('ハンドラ内で別の例外'); // ← 危険
});
// ✅ ハンドラ内での例外は try-catch で処理する
set_exception_handler(function (\Throwable $e): void {
try {
// 何らかのログ記録処理
riskyLogOperation();
} catch (\Throwable $inner) {
error_log('ハンドラ内エラー: ' . $inner->getMessage());
}
exit(1);
});
まとめ
| 項目 | 内容 |
|---|---|
| 関数名 | set_exception_handler(?callable $callback): callable|null |
| 主な用途 | try-catch で捕捉されなかった例外のグローバルハンドラを登録 |
| 対象 | Throwable(Exception + Error の両方) |
| ハンドラ後の動作 | スクリプト終了(処理は継続できない) |
| 戻り値 | 以前のハンドラ(callable)または null |
| 復元 | restore_exception_handler() または set_exception_handler(null) |
| 組み合わせ推奨 | set_error_handler() + register_shutdown_function() + error_get_last() |
set_exception_handler() は「最後の砦」です。try-catch をどれだけ丁寧に書いても、想定外の例外は必ず発生します。このハンドラでユーザーへの安全な表示・詳細なログ記録・開発者への通知を確実に行うことで、例外が握りつぶされることも内部情報が露出することもない堅牢なアプリケーションを実現できます。
