PHPの例外処理とは、処理の途中で起きた問題を throw で知らせ、try で囲んだ範囲の中で起きたものを catch で受け取り、finally で後始末をする仕組みです。 受け取れるのは Throwable というインターフェースを持つオブジェクトで、その下に Exception 系と Error 系の2つの系統があります。

入門記事の多くは Exception だけを扱いますが、PHP 7 以降では型の不一致やゼロ除算は Error 系として投げられるため、catch (Exception $e) では受け取れません。この記事では、すべてのコードを PHP 8.4.21 で実行し、その出力をそのまま載せています。 「どの例外がどちらの系統か」「finally と return はどちらが先か」といった迷いやすい点を、実行結果で確かめながら整理します。

PHP例外処理の早見表

構文の一覧です。対応バージョンのうち、複数 catch・変数なし catch・throw 式の3つは PHP マニュアルの「例外」のページを今回開いて確認しました。それ以外は一般に知られた仕様です。

やりたいこと 書き方 使えるバージョン
例外を投げる throw new InvalidArgumentException('メッセージ'); PHP 5〜
例外を受け取る try { ... } catch (InvalidArgumentException $e) { ... } PHP 5〜
必ず後始末をする finally { ... } PHP 5.5〜
Error も Exception もまとめて受け取る catch (Throwable $e) PHP 7.0〜
複数の型を1つの catch で受け取る catch (JsonException | TypeError $e) PHP 7.1〜
変数を使わずに受け取る catch (JsonException) { ... } PHP 8.0〜
式の中で投げる(throw 式) $v = $a['k'] ?? throw new Exception('必須'); PHP 8.0〜
元の例外を保ったまま包む throw new MyException('説明', 0, $e); PHP 5.3〜
独自の例外を作る class MyException extends RuntimeException {} PHP 5〜
警告を例外に変える set_error_handler() で ErrorException を投げる PHP 5.1〜
受け取られなかった例外の受け皿 set_exception_handler() PHP 5〜

Error と Exception の違いは次のとおりです。

比べる点 Exception 系 Error 系
主な発生元 自分のコードやライブラリが throw する PHP 本体が投げる(型の不一致、ゼロ除算、未定義の関数など)
意味 入力や外部の状態によって起こりうる、想定できる問題 コードの書き方に原因があることが多い問題
catch (Exception $e) 受け取れる 受け取れない
catch (Throwable $e) 受け取れる 受け取れる
代表例 InvalidArgumentException、JsonException、PDOException TypeError、DivisionByZeroError、ValueError
基本の方針 対処できる場所で catch する コードを直す。最上位でまとめて記録する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PHP例外処理 try catch finally|独自例外とErrorの違い」

try・catch・finallyの基本の書き方

まずは最小の形です。備品の在庫数を調べ、登録がなければ例外を投げます。

<?php
declare(strict_types=1);

function findItem(array $stock, string $name): int
{
    if (!array_key_exists($name, $stock)) {
        throw new InvalidArgumentException("在庫に {$name} がありません");
    }
    return $stock[$name];
}

$stock = ['pen' => 12, 'note' => 0];

foreach (['pen', 'desk'] as $name) {
    try {
        echo "{$name}: " . findItem($stock, $name) . " 個\n";
    } catch (InvalidArgumentException $e) {
        echo "catch: " . $e->getMessage() . "\n";
    } finally {
        echo "finally: {$name} の確認を終了\n";
    }
}

実行結果(PHP 8.4.21):

pen: 12 個
finally: pen の確認を終了
catch: 在庫に desk がありません
finally: desk の確認を終了

流れを表にすると次のようになります。

ブロック 実行されるとき 役割
try 常に 例外が起こりうる処理を囲む
catch try の中で、指定した型の例外が投げられたとき 例外を受け取って対処する
finally 例外があってもなくても、最後に必ず ファイルを閉じる、ロックを外すなどの後始末

例外が投げられると、try の中の残りの行は飛ばされて、型が合う catch へ移ります。 上の例では、desk のとき echo "{$name}: ..." は出力されていません。

例外オブジェクトから取り出せる情報は次のとおりです。

メソッド 取り出せるもの
getMessage() メッセージ
getCode() コード(整数。省略時は 0)
getPrevious() 包まれている元の例外(なければ null)
getFile() / getLine() 例外が作られたファイルと行
getTrace() / getTraceAsString() 呼び出しの経路(スタックトレース)

Throwableの階層図とErrorとExceptionの違い

throw できるのは、Throwable インターフェースを持つオブジェクトだけです。Throwable の下に Error と Exception があり、それぞれにさらに細かいクラスがぶら下がっています。

classDiagram
  Throwable <|-- Error
  Throwable <|-- Exception
  Error <|-- TypeError
  TypeError <|-- ArgumentCountError
  Error <|-- ValueError
  Error <|-- ArithmeticError
  ArithmeticError <|-- DivisionByZeroError
  Error <|-- UnhandledMatchError
  Error <|-- CompileError
  CompileError <|-- ParseError
  Error <|-- AssertionError
  Exception <|-- ErrorException
  Exception <|-- JsonException
  Exception <|-- LogicException
  Exception <|-- RuntimeException
  LogicException <|-- InvalidArgumentException
  LogicException <|-- DomainException
  LogicException <|-- LengthException
  LogicException <|-- OutOfRangeException
  RuntimeException <|-- OutOfBoundsException
  RuntimeException <|-- RangeException
  RuntimeException <|-- UnexpectedValueException
  RuntimeException <|-- PDOException
  class Throwable {
    <<interface>>
  }

この図は、PHP 8.4.21 で宣言済みのクラスを親子関係でたどった結果から、よく使うものを抜き出したものです。ほかにも DateError、FiberError、Random\RandomError(Error 系)、DateException、ReflectionException、RequestParseBodyException(Exception 系)などがあります。

どの例外がいつ発生するかを実行して確かめる

次のコードは、いろいろな問題をわざと起こし、まず catch (Exception $e) で受け、受け取れなかったものを外側の catch (Error $e) で受けて、どちらで捕まったかを表示します。

<?php
declare(strict_types=1);

function half(int $n): int { return intdiv($n, 2); }

$cases = [
    'TypeError'           => fn() => half("10"),
    'DivisionByZeroError' => fn() => 10 % 0,
    'DivisionByZeroError(/)' => fn() => 10 / 0,
    'ArgumentCountError'  => fn() => half(),
    'ValueError'          => fn() => str_repeat('a', -1),
    'Error(未定義関数)'    => fn() => no_such_function(),
    'Error(nullのメソッド)' => fn() => null->run(),
    'UnhandledMatchError' => fn() => match (5) { 1 => 'a' },
    'JsonException'       => fn() => json_decode('{bad', flags: JSON_THROW_ON_ERROR),
    'ParseError'          => fn() => eval('echo 1 +;'),
    'ArithmeticError'     => fn() => intdiv(PHP_INT_MIN, -1),
    'DateMalformedStringException' => fn() => new DateTime('not a date'),
    'RangeException等(自分でthrow)' => fn() => throw new RangeException('範囲外'),
];

foreach ($cases as $label => $fn) {
    try {
        try {
            $fn();
            echo "{$label}: 例外なし\n";
        } catch (Exception $e) {
            echo "[Exceptionで捕捉] " . get_class($e) . ": " . $e->getMessage() . "\n";
        }
    } catch (Error $e) {
        echo "[Exceptionでは拾えずErrorで捕捉] " . get_class($e) . ": " . $e->getMessage() . "\n";
    }
}

実行結果(PHP 8.4.21。ファイルのパスは環境に合わせて置き換えています):

[Exceptionでは拾えずErrorで捕捉] TypeError: half(): Argument #1 ($n) must be of type int, string given, called in /home/taro/app/02_error.php on line 7
[Exceptionでは拾えずErrorで捕捉] DivisionByZeroError: Modulo by zero
[Exceptionでは拾えずErrorで捕捉] DivisionByZeroError: Division by zero
[Exceptionでは拾えずErrorで捕捉] ArgumentCountError: Too few arguments to function half(), 0 passed in /home/taro/app/02_error.php on line 10 and exactly 1 expected
[Exceptionでは拾えずErrorで捕捉] ValueError: str_repeat(): Argument #2 ($times) must be greater than or equal to 0
[Exceptionでは拾えずErrorで捕捉] Error: Call to undefined function no_such_function()
[Exceptionでは拾えずErrorで捕捉] Error: Call to a member function run() on null
[Exceptionでは拾えずErrorで捕捉] UnhandledMatchError: Unhandled match case of type int
[Exceptionで捕捉] JsonException: Syntax error
[Exceptionでは拾えずErrorで捕捉] ParseError: syntax error, unexpected token ";"
[Exceptionでは拾えずErrorで捕捉] ArithmeticError: Division of PHP_INT_MIN by -1 is not an integer
[Exceptionで捕捉] DateMalformedStringException: Failed to parse time string (not a date) at position 0 (n): The timezone could not be found in the database
[Exceptionで捕捉] RangeException: 範囲外

13件のうち、catch (Exception $e) で受け取れたのは3件だけでした。結果を「いつ発生するか」の一覧にまとめます。

クラス 系統 いつ発生するか 上の実行でのメッセージ
TypeError Error 引数や戻り値の型が宣言と合わない must be of type int, string given
ArgumentCountError Error(TypeError の子) 引数の数が足りない Too few arguments to function half()
ValueError Error 型は合っているが値が受け付けられない must be greater than or equal to 0
DivisionByZeroError Error(ArithmeticError の子) 0 で割る(/、%、intdiv()) Division by zero / Modulo by zero
ArithmeticError Error 整数で表せない計算結果 Division of PHP_INT_MIN by -1 is not an integer
Error Error 未定義の関数を呼ぶ、null のメソッドを呼ぶ Call to undefined function / Call to a member function run() on null
UnhandledMatchError Error match 式でどの条件にも当てはまらない Unhandled match case of type int
ParseError Error(CompileError の子) eval() や include したコードに構文の誤りがある syntax error, unexpected token ";"
JsonException Exception JSON_THROW_ON_ERROR をつけた JSON の変換で問題がある Syntax error
DateMalformedStringException Exception(DateException の子) 日付として解釈できない文字列を渡す Failed to parse time string
RangeException など Exception 自分のコードやライブラリが throw する 範囲外

PHP 本体が「コードの書き方」に対して投げるものは Error 系、入力データや外部の状態に対して投げるものは Exception 系、という分かれ方になっています。

catch (Exception $e) で受け取れないと処理はどうなるか

Error 系を catch (Exception $e) だけで囲んだ場合の動きです。

<?php
try {
    echo intdiv(10, 0);
} catch (Exception $e) {
    echo "ここには来ません\n";
}
echo "この行も実行されません\n";

実行結果:

PHP Fatal error:  Uncaught DivisionByZeroError: Division by zero in /home/taro/app/03_uncaught.php:3
Stack trace:
#0 /home/taro/app/03_uncaught.php(3): intdiv()
#1 {main}
  thrown in /home/taro/app/03_uncaught.php on line 3

catch を素通りし、「Uncaught(受け取られなかった)」として Fatal error になり、スクリプトはそこで止まりました。 終了コードは 255 でした。

直し方は、目的に応じて3つあります。

目的 書き方
ゼロ除算だけを受けたい catch (DivisionByZeroError $e)
Error 系も Exception 系もまとめて受けたい catch (Throwable $e)
そもそも起こさない 割る前に if ($n === 0) で確かめる
<?php
try {
    echo intdiv(10, 0);
} catch (DivisionByZeroError $e) {
    echo "0 では割れません\n";
}

Error 系は、受け取って続行するより、原因のコードを直すのが基本です。catch (Throwable $e) は、アプリケーションの最上位で記録を残す場面に向いています。

名前空間の中では先頭に \ をつける

もう1つ、catch を素通りする原因があります。名前空間(namespace)を宣言したファイルで、Exception を先頭の \ なしで書いた場合です。

<?php
namespace App\Demo;
try {
    throw new \RuntimeException('x');
} catch (Exception $e) {
    echo "catch できました\n";
}

実行結果:

PHP Fatal error:  Uncaught RuntimeException: x in /home/taro/app/nf1b.php:4

catch (Exception $e) は App\Demo\Exception という存在しないクラスを指すことになり、エラーも出ないまま素通りします。名前空間の中では catch (\Exception $e) と書くか、ファイルの先頭に use Exception; を書きます。

finallyとreturnの実行順

finally は「必ず実行される」ブロックですが、try や catch の中に return があるときの順番は迷いやすいところです。5つの形をまとめて実行します。

<?php
function a(): string {
    try {
        echo "1. try\n";
        return "tryの戻り値";
    } finally {
        echo "2. finally\n";
    }
}
echo "3. " . a() . "\n\n";

function b(): string {
    try {
        throw new RuntimeException('x');
    } catch (RuntimeException $e) {
        return "catchの戻り値";
    } finally {
        return "finallyの戻り値";
    }
}
echo "b(): " . b() . "\n";

function c(): string {
    try {
        throw new RuntimeException('この例外は外へ出ません');
    } finally {
        return "finallyのreturnが例外を上書き";
    }
}
echo "c(): " . c() . "\n";

function d(): int {
    $n = 1;
    try {
        return $n;
    } finally {
        $n = 99;
    }
}
echo "d(): " . d() . "\n\n";

function e(): void {
    try {
        echo "try\n";
        throw new LogicException('未捕捉');
    } finally {
        echo "finally(catchがなくても実行)\n";
    }
}
try { e(); } catch (LogicException $e) { echo "外側でcatch: " . $e->getMessage() . "\n"; }

実行結果:

1. try
2. finally
3. tryの戻り値

b(): finallyの戻り値
c(): finallyのreturnが例外を上書き
d(): 1

try
finally(catchがなくても実行)
外側でcatch: 未捕捉
関数 形 結果 わかること
a try で return、finally で出力 try → finally → 呼び出し元の順 return の値は先に決まり、finally を実行してから呼び出し元へ戻る
b catch と finally の両方で return finally の値が返る finally の return が優先される
c 例外が出たまま finally で return 例外が消えて値が返る finally の return は投げられた例外を打ち消す
d try で $n を return、finally で $n を書き換え 1 が返る return の値は return の時点で確定している
e catch なしの try-finally finally のあと例外が外へ catch がなくても finally は動く

PHP マニュアルにも、return 文はその時点で評価され、finally の実行後に結果が返される、と書かれています。

c の動きには注意が要ります。例外が起きたことが呼び出し元に伝わらなくなるため、finally には return を書かず、後始末だけを書くのがおすすめです。

複数catchと書く順番

型ごとに対処を分ける・まとめる

catch は複数並べられます。同じ対処でよい型は | でまとめられます。

<?php
declare(strict_types=1);

function parseQuantity(string $raw): int
{
    $data = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
    return intdiv(100, $data['qty']);
}

foreach (['{"qty":4}', '{bad', '{"qty":0}', '{"qty":"x"}'] as $raw) {
    try {
        echo parseQuantity($raw) . "\n";
    } catch (JsonException | TypeError $e) {
        echo "入力の形式: " . get_class($e) . "\n";
    } catch (DivisionByZeroError) {
        echo "数量が0です\n";
    }
}

実行結果:

25
入力の形式: JsonException
数量が0です
入力の形式: TypeError

JsonException(Exception 系)と TypeError(Error 系)のように、系統が違う型も | で1つにまとめられます。 2つめの catch は変数を書かない形(PHP 8.0 以降)です。例外オブジェクトを使わないときは、この形にすると意図が伝わりやすくなります。

親クラスを先に書くと子クラスの catch に届かない

catch は上から順に型を比べ、最初に合ったものだけが実行されます。

<?php
try {
    throw new InvalidArgumentException('引数が不正');
} catch (Exception $e) {
    echo "Exception側: " . get_class($e) . "\n";
} catch (InvalidArgumentException $e) {
    echo "InvalidArgumentException側\n";
}

実行結果:

Exception側: InvalidArgumentException

InvalidArgumentException は Exception の子なので、先に書いた catch (Exception $e) に合ってしまい、2つめには届きません。エラーや警告も出ません。catch は「細かい型を上、広い型を下」の順に並べます。

並べる順 例
1(上) catch (OutOfStockException $e) 自分で作った具体的な型
2 catch (InvalidArgumentException $e) 標準の具体的な型
3 catch (RuntimeException $e) 親の型
4(下) catch (Throwable $e) すべて

独自例外クラスの作り方

独自例外は、Exception かその子クラスを継承して作ります。最小の形はクラス名を決めるだけです。

class ItemNotFoundException extends RuntimeException {}

これだけでも「型で区別して catch できる」という大きな利点があります。追加の情報を持たせたいときは、コンストラクタでプロパティを受け取ります。

<?php
declare(strict_types=1);

// アプリ共通の親
class AppException extends RuntimeException {}

// 追加情報を持つ独自例外
final class OutOfStockException extends AppException
{
    public function __construct(
        public readonly string $itemName,
        public readonly int $requested,
        public readonly int $available,
        ?Throwable $previous = null,
    ) {
        parent::__construct(
            "{$itemName} の在庫が足りません(希望 {$requested} / 在庫 {$available})",
            1001,
            $previous
        );
    }
}

final class ItemNotFoundException extends AppException {}

function reserve(array $stock, string $name, int $qty): int
{
    $available = $stock[$name] ?? throw new ItemNotFoundException("{$name} は登録されていません");
    if ($available < $qty) {
        throw new OutOfStockException($name, $qty, $available);
    }
    return $available - $qty;
}

$stock = ['pen' => 3];
foreach ([['pen', 2], ['pen', 5], ['desk', 1]] as [$name, $qty]) {
    try {
        echo "残り " . reserve($stock, $name, $qty) . "\n";
    } catch (OutOfStockException $e) {
        echo "在庫不足: {$e->itemName} あと " . ($e->requested - $e->available) . " 個必要 (code={$e->getCode()})\n";
    } catch (AppException $e) {
        echo get_class($e) . ": {$e->getMessage()}\n";
    }
}

実行結果:

残り 1
在庫不足: pen あと 2 個必要 (code=1001)
ItemNotFoundException: desk は登録されていません

独自例外を作るときのポイントです。

ポイント 理由
アプリ共通の親クラス(ここでは AppException)を1つ作る 「自分のアプリの例外」をまとめて catch できる
必ず parent::__construct() を呼ぶ メッセージ・コード・previous が正しく設定される
追加情報は public readonly のプロパティで持つ catch した側がメッセージの文字列を分解せずに値を使える
$previous を引数に用意しておく 元の例外を包んで渡せる(次の節)
継承元は Exception 系にする 利用側の catch (Exception $e) で受け取れる

なお、Throwable を直接 implements したクラスは作れません。試すと次のメッセージで止まります。

PHP Fatal error:  Class My cannot implement interface Throwable, extend Exception or Error instead

メッセージのとおり、Exception か Error を継承します。

標準の例外クラスの選び方

独自例外の親や、そのまま投げる型を選ぶときの目安です。

クラス 親 向いている場面
InvalidArgumentException LogicException 引数の値が受け付けられない
DomainException LogicException 値が決められた種類に含まれない
LengthException LogicException 長さが決まりに合わない
OutOfRangeException LogicException 存在しない添字(コードを直せば防げるもの)
BadMethodCallException BadFunctionCallException 呼べないメソッドを呼んだ
RuntimeException Exception 実行してみないとわからない問題全般
OutOfBoundsException RuntimeException 存在しないキー(実行時のデータによるもの)
RangeException RuntimeException 計算結果が範囲に収まらない
UnexpectedValueException RuntimeException 戻り値などが想定した型や値でない

LogicException 系は「コードを直せば起きなくなる問題」、RuntimeException 系は「実行時の入力や環境で起こりうる問題」と考えると選びやすくなります。

previousで例外をつなげる(例外チェーン)

下の層で起きた例外を、上の層で意味のわかる例外に包み直すことがあります。このとき元の例外を第3引数(previous)に渡すと、原因をたどれる状態のまま包めます。

<?php
declare(strict_types=1);

class ConfigLoadException extends RuntimeException {}

function loadConfig(string $json): array
{
    try {
        return json_decode($json, true, flags: JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        // 元の例外を第3引数 previous に渡して包む
        throw new ConfigLoadException('設定を読み込めませんでした', 0, $e);
    }
}

try {
    loadConfig('{"debug": tru}');
} catch (ConfigLoadException $e) {
    for ($cur = $e, $i = 0; $cur !== null; $cur = $cur->getPrevious(), $i++) {
        echo str_repeat('  ', $i) . get_class($cur) . ': ' . $cur->getMessage()
            . ' (' . basename($cur->getFile()) . ':' . $cur->getLine() . ")\n";
    }
}

実行結果:

ConfigLoadException: 設定を読み込めませんでした (08_previous.php:12)
  JsonException: Syntax error (08_previous.php:9)

getPrevious() をたどると、包む前の JsonException と、それが起きた行(9行目)まで取り出せました。

書き方 原因の情報
throw new ConfigLoadException('設定を読み込めませんでした'); 元の例外の型・メッセージ・行が残らない
throw new ConfigLoadException('設定を読み込めませんでした', 0, $e); getPrevious() でたどれる

同じ例外をそのまま上へ渡す(再スロー)

記録だけして、対処は呼び出し元に任せたいときは throw $e; と書きます。

<?php
function step(): void {
    try {
        throw new RuntimeException('接続が切れました');
    } catch (RuntimeException $e) {
        echo "ログに記録: {$e->getMessage()}\n";
        throw $e; // 同じ例外をそのまま上へ
    } finally {
        echo "finally: 後始末\n";
    }
}
try { step(); } catch (Throwable $e) {
    echo "最上位: " . get_class($e) . " line=" . $e->getLine() . "\n";
    echo $e->getTraceAsString() . "\n";
}

実行結果(パスは環境に合わせて置き換えています):

ログに記録: 接続が切れました
finally: 後始末
最上位: RuntimeException line=4
#0 /home/taro/app/12_rethrow.php(12): step()
#1 {main}

再スローしても、行番号は最初に例外が作られた4行目のままです。catch の中で throw しても finally は実行され、そのあとで外側の catch に届いています。

PHP 8のthrow式と変数なしcatch

PHP 8.0 から、throw は「文」ではなく「式」になりました。値を書ける場所ならどこにでも置けます。

<?php
declare(strict_types=1);

$input = ['name' => 'pen'];

// ?? と組み合わせる
try {
    $price = $input['price'] ?? throw new InvalidArgumentException('price は必須です');
} catch (InvalidArgumentException $e) {
    echo "1: {$e->getMessage()}\n";
}

// 三項演算子
try {
    $qty = -1;
    $checked = $qty >= 0 ? $qty : throw new RangeException('qty は0以上');
} catch (RangeException $e) {
    echo "2: {$e->getMessage()}\n";
}

// アロー関数
$fail = fn(string $m) => throw new LogicException($m);
try { $fail('アロー関数からthrow'); } catch (LogicException $e) { echo "3: {$e->getMessage()}\n"; }

// match の default
try {
    $status = 'unknown';
    $label = match ($status) {
        'open' => '受付中',
        'done' => '完了',
        default => throw new UnexpectedValueException("未知の状態: {$status}"),
    };
} catch (UnexpectedValueException $e) {
    echo "4: {$e->getMessage()}\n";
}

// 変数なし catch(PHP 8.0〜)
try {
    new DateTimeImmutable('2026-13-45');
} catch (Exception) {
    echo "5: 日付として解釈できませんでした\n";
}

実行結果:

1: price は必須です
2: qty は0以上
3: アロー関数からthrow
4: 未知の状態: unknown
5: 日付として解釈できませんでした
書ける場所 例 置き換えられる書き方
?? の右側 $a['k'] ?? throw new ... if (!isset($a['k'])) { throw ...; }
三項演算子 $ok ? $v : throw new ... if 文
アロー関数の本体 fn() => throw new ... 通常の無名関数
match の腕 default => throw new ... match の前後の if 文

「値がなければ例外」を1行で書けるので、入力チェックが短くなります。 match では、default に throw を置くことで、標準の UnhandledMatchError の代わりに、意味のわかるメッセージを持つ例外を投げられます。

警告を例外に変えるset_error_handler

PHP には、例外とは別に「警告(Warning)」や「通知(Notice)」という仕組みがあります。たとえば存在しないファイルを file() で開くと、例外ではなく警告が出て、処理はそのまま続きます。

<?php
$lines = file('/no/such/file.txt');
echo "警告のあとも処理は続きます\n";
var_dump($lines);

実行結果:

PHP Warning:  file(/no/such/file.txt): Failed to open stream: No such file or directory in /home/taro/app/11_nohandler.php on line 2
警告のあとも処理は続きます
bool(false)

警告は try-catch では受け取れません。set_error_handler() で警告を ErrorException に変換すると、ほかの例外と同じように扱えます。あわせて、どこでも catch されなかった例外の受け皿になる set_exception_handler() も登録します。次のコードは、アプリケーションの入口のファイルの先頭にそのまま貼って使えます。

<?php
declare(strict_types=1);

// ① 警告・通知を ErrorException に変える
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
    if (!(error_reporting() & $severity)) {
        return false; // @ や error_reporting で抑制されたものは標準の処理に任せる
    }
    throw new ErrorException($message, 0, $severity, $file, $line);
});

// ② どこでも catch されなかった例外の最終受け皿
set_exception_handler(function (Throwable $e): void {
    error_log(sprintf('[未捕捉] %s: %s (%s:%d)', get_class($e), $e->getMessage(), basename($e->getFile()), $e->getLine()));
    if (PHP_SAPI !== 'cli') {
        http_response_code(500);
    }
    echo "エラーが発生しました。時間をおいてもう一度お試しください。\n";
});

// --- 動作確認 ---
try {
    $lines = file('/no/such/file.txt');   // 通常は Warning
} catch (ErrorException $e) {
    echo "catch: " . $e->getMessage() . " (severity=" . $e->getSeverity() . ")\n";
}

try {
    $arr = [];
    echo $arr['missing'];                  // 通常は Warning: Undefined array key
} catch (ErrorException $e) {
    echo "catch: " . $e->getMessage() . "\n";
}

$r = @file('/no/such/file.txt');           // @ 付きは例外にしない
var_dump($r);

throw new DomainException('どこでも捕まえていない例外');
echo "ここは実行されません\n";

実行結果([未捕捉] の行は error_log の出力先である標準エラーに出たものです):

catch: file(/no/such/file.txt): Failed to open stream: No such file or directory (severity=2)
catch: Undefined array key "missing"
bool(false)
[未捕捉] DomainException: どこでも捕まえていない例外 (10_handler.php:38)
エラーが発生しました。時間をおいてもう一度お試しください。
確認できたこと 説明
警告が catch できた file() の警告が ErrorException になった。severity=2 は E_WARNING
未定義のキーも catch できた 配列の存在しないキーの読み取りも例外になった
@ をつけた行は例外にならない error_reporting() & $severity の判定で標準の処理に戻している
最終受け皿が動いた catch されなかった DomainException がハンドラに渡り、記録と案内の表示ができた
ハンドラのあとは続行しない 最後の echo は実行されず、スクリプトは終了した

2つのハンドラの役割の違いです。

関数 受け取るもの 役割
set_error_handler() Warning・Notice・Deprecated など(例外ではないもの) 例外に変換して、try-catch の流れに乗せる
set_exception_handler() どこでも catch されなかった Throwable 記録を残し、利用者には一般的な案内だけを表示する

最終受け皿では、例外のメッセージやスタックトレースを画面にそのまま出さず、ログに記録するようにします。内部のファイル構成などが利用者に見えるのを防ぐためです。Laravel などのフレームワークは、同様の仕組みを最初から備えています。

catchするか呼び出し元に任せるかの判断表

「すべての処理を try-catch で囲む」必要はありません。その場で対処できるかどうかで決めます。

状況 おすすめ 例
その場で代わりの値や処理を用意できる catch して対処する 設定ファイルが読めなければ既定値を使う
意味のわかる例外に言い換えたい catch して previous つきで包み直す JsonException を ConfigLoadException に
記録だけ残したい catch して記録し、throw $e; で再スロー 途中の層でログを残す
後始末だけ必要 try-finally(catch なし) 一時ファイルの削除、ロックの解放
その場では何もできない catch しない。呼び出し元に任せる 下位の関数の中
型の不一致などコードの誤り(Error 系) コードを直す。最上位で記録する TypeError、未定義の関数
アプリケーションの入口 catch (Throwable $e) か set_exception_handler() 記録と、利用者向けの案内

よくあるつまずきと直し方もまとめます。

つまずき 起きること 直し方
catch (Exception $e) で TypeError を受けようとする Fatal error: Uncaught TypeError で止まる catch (TypeError $e) か catch (Throwable $e)
名前空間の中で catch (Exception $e) 素通りして Uncaught になる catch (\Exception $e) か use Exception;
親クラスの catch を先に書く 子クラスの catch に届かない 細かい型を上に並べる
catch の中を空にする 問題が起きたことが誰にも伝わらない 記録を残すか、再スローする
finally に return を書く 投げられた例外が消える finally は後始末だけにする
包み直すときに previous を渡さない 元の原因がたどれない 第3引数に $e を渡す
警告を try-catch で受けようとする 受け取れず処理が続く set_error_handler() で ErrorException に変える

よくある質問

Q. Exception と Error はどう使い分けますか? A. 自分で投げるのは Exception 系です。Error 系は主に PHP 本体が、型の不一致やゼロ除算などコードの書き方に原因がある問題に対して投げます。今回の実行では、13件のうち catch (Exception $e) で受け取れたのは JsonException、DateMalformedStringException、RangeException の3件で、残りは Error 系でした。

Q. finally は return のあとでも実行されますか? A. 実行されます。try の中で return しても、finally を実行してから呼び出し元へ戻ります。戻り値は return の時点で決まっているので、finally で変数を書き換えても戻り値は変わりません。ただし finally の中に return を書くと、その値が優先され、投げられた例外も打ち消されます。

Q. catch (Throwable $e) を常に使えばよいですか? A. アプリケーションの入口で記録を残す用途には向いています。途中の処理で使うと、型の不一致のような「コードを直すべき問題」まで受け取ってしまい、気づきにくくなります。途中の処理では、対処できる具体的な型を指定するのがおすすめです。

Q. 独自例外はどのクラスを継承すればよいですか? A. 迷ったら RuntimeException を継承します。実行時の入力や環境で起こりうる問題を表す型です。引数の誤りのようにコードを直せば防げる問題なら LogicException 系(InvalidArgumentException など)が合います。アプリ共通の親クラスを1つ作り、そこから派生させるとまとめて catch できます。

まとめ

  • throw できるのは Throwable。その下に Exception 系と Error 系があります。
  • TypeError・DivisionByZeroError・ValueError などは Error 系で、catch (Exception $e) では受け取れません。まとめて受けるなら catch (Throwable $e) です。
  • finally は return のあとでも実行されます。 finally に return を書くと例外が打ち消されるので、後始末だけを書きます。
  • catch は細かい型を上、広い型を下に並べます。 同じ対処の型は | でまとめられます(PHP 7.1〜)。
  • 包み直すときは第3引数に元の例外(previous)を渡すと、getPrevious() で原因までたどれます。
  • PHP 8.0 からは throw を式として書け、catch の変数も省略できます。
  • set_error_handler で警告を ErrorException に変え、set_exception_handler を最終受け皿にすると、問題の扱いを例外に一本化できます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「PHP例外処理 try catch finally|独自例外とErrorの違い」

株式会社RJCでは、PHPの例外処理のような基礎の仕組みを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?