公開日 2026-09-15

Slim 4の例外レスポンスをJSONに統一する(ErrorHandler継承とAPP_ENV出し分け)

Slim 4 で 404・405・500 や Action から投げた例外を、error.code と error.message を持つ JSON 形へ統一する。ErrorHandler を継承して respond() を override し、APP_ENV で詳細を出し分ける。

目次

  1. 前提環境
  2. 1. 統一する形と方針
  3. なぜ ErrorHandler を継承するのか
  4. 2. デモ環境を用意する
  5. 3. 例外をJSONに変換するエラーハンドラ
  6. コードのポイント
  7. 4. 手組み配線に組み込む
  8. コードのポイント
  9. 5. curl で確認する
  10. 6. PHPUnit で形を固定する
  11. コードのポイント
  12. 7. まとめ

対象読者: Slim 4 で API を作っていて、404・405・500 や Action から投げた例外を、すべて同じ JSON 形に揃えたい方

この記事では、Slim 4 のあらゆるエラー応答を {"error":{"code","message"}} という 1 つの形に統一します。ルート未一致の 404、メソッド違いの 405、Action が投げた例外、未捕捉の RuntimeException まで、まとめて同じ JSON で返すのがゴールです。仕組みは、Slim 同梱の ErrorHandler を継承し、respond() だけを差し替える方法を使います。あわせて APP_ENV で 500 の詳細を出し分けます。

題材は品目照会 API(GET /api/items/{code})の 1 本だけ。永続化は扱いません。動作確認は curl と PHPUnit の 2 本立てです。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL連携有効)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。アプリ用コンテナのサービス名は app に固定しておきます。

1. 統一する形と方針

返したいエラー応答は、次の 1 形だけです。

{"error":{"code":"not_found","message":"Item not found: NOPE"}}
  • code: 機械可読の文字列(not_found / method_not_allowed / bad_request / internal_error など)。クライアントはこれで分岐する
  • message: 人間向けの短い説明。HTTP ステータスはステータス行にあるので本文には入れない

仕上げる挙動は 3 つあります。

  • ルート未一致・メソッド不一致(404 / 405)が JSON になる
  • Action が投げた HttpNotFoundException も同じ形になる
  • 未捕捉の例外(RuntimeException など)が 500 の JSON になり、本番では詳細を伏せる

今回の範囲外は次の点です。

  • 入力値の検証(存在チェックや 400 への変換は別記事)
  • 認証・認可
  • 永続化(品目はメモリ上の固定データで足りる)

なぜ ErrorHandler を継承するのか

エラー応答を JSON にする方法は 2 つあります。1 つは独自の呼び出し可能クラスを setDefaultErrorHandler() に渡す方法で、もう 1 つが Slim 同梱の Slim\Handlers\ErrorHandler を継承する方法です。今回は後者です。継承すると、HTTP 例外からステータスコードを決める処理(404・405 などの振り分け)は親クラスに任せられ、こちらは出力の形を作る respond() だけに集中できます。

どの入口から来た例外も、最終的に 1 つのハンドラへ集まる形です。

flowchart TD
    A["ルート未一致 / メソッド不一致"] --> EM["ErrorMiddleware"]
    B["Action が投げる HttpException"] --> EM
    C["未捕捉の例外 RuntimeException など"] --> EM
    EM --> H["JsonErrorHandler::respond()"]
    H --> J["error.code と error.message を持つ JSON"]

2. デモ環境を用意する

WSL 側のシェルで作業します。Windows 側から始める場合は、wsl で Ubuntu に入ってから実行してください。

次のコマンドで作業ディレクトリを用意します。

# Windows側から始める場合のみ実行
# wsl
mkdir -p ~/projects/slim4-json-error-handler-demo
cd ~/projects/slim4-json-error-handler-demo
mkdir -p docker/php app public src/Action src/Handler tests/Http
code .

compose.yml を作成します。今回は DB を使わないので、アプリ用コンテナ 1 つだけです。

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /workspace
    volumes:
      - ./:/workspace
    ports:
      - "8080:8080"
    environment:
      APP_ENV: dev
    command: ["sleep", "infinity"]

docker/php/Dockerfile を作成します。unzip は、Composer がアーカイブを展開するのに必要です。

FROM php:8.5-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends unzip \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /workspace

composer.json を作成します。本番コードは App\ 名前空間、テストは Tests\ 名前空間です。オートロードを 2 系統に分けておきます。

{
  "name": "example/slim4-json-error-handler-demo",
  "type": "project",
  "require": {},
  "require-dev": {},
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }
}

コンテナを起動し、依存パッケージを入れます。バージョンを固定しておくと、環境ごとの差分が少なくなり、再現しやすくなります。

docker compose up -d --build
docker compose exec app composer require slim/slim:^4.15 slim/psr7:^1.8
docker compose exec app composer require --dev phpunit/phpunit:^11.5

HTTP サーバーは 5 章で手動起動します。

詰まり時:

  • Class not found が出る: docker compose exec app composer dump-autoload を実行する
  • http://localhost:8080 へつながらない: php -S を起動したターミナルが生きているか確認する

3. 例外をJSONに変換するエラーハンドラ

ここが中心です。Slim\Handlers\ErrorHandler を継承し、差し替えるのは応答を組み立てる respond() だけです。src/Handler/JsonErrorHandler.php を作成します。

<?php
declare(strict_types=1);

namespace App\Handler;

use Psr\Http\Message\ResponseInterface;
use Slim\Handlers\ErrorHandler;

final class JsonErrorHandler extends ErrorHandler
{
    protected function respond(): ResponseInterface
    {
        $status = $this->statusCode;

        $code = match (true) {
            $status === 400 => 'bad_request',
            $status === 401 => 'unauthorized',
            $status === 403 => 'forbidden',
            $status === 404 => 'not_found',
            $status === 405 => 'method_not_allowed',
            $status >= 500 => 'internal_error',
            default => 'error',
        };

        $payload = [
            'error' => [
                'code' => $code,
                'message' => $this->buildMessage($status),
            ],
        ];

        $response = $this->responseFactory->createResponse($status);
        $response->getBody()->write(
            (string) json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)
        );

        return $response->withHeader('Content-Type', 'application/json');
    }

    private function buildMessage(int $status): string
    {
        if ($status >= 500) {
            return $this->displayErrorDetails
                ? $this->exception->getMessage()
                : 'Internal Server Error';
        }

        $message = $this->exception->getMessage();

        return $message !== '' ? $message : 'Request error';
    }
}

コードのポイント

① 親が決めた statusCode を使い、出力だけ作る(JsonErrorHandler.php)

        $status = $this->statusCode;

        $code = match (true) {

ErrorHandler は、__invoke() の中で例外の種類からステータスコードを先に決め、それを $this->statusCode に入れてから respond() を呼びます。HttpNotFoundException なら 404、HttpMethodNotAllowedException なら 405、それ以外は 500 です。こちらはその値を読むだけで、振り分けロジックを自前で持たずに済みます。

② 5xx は displayErrorDetails で伏せる(JsonErrorHandler.php)

        if ($status >= 500) {
            return $this->displayErrorDetails
                ? $this->exception->getMessage()
                : 'Internal Server Error';
        }

500 系は内部の例外メッセージが混ざりやすい部分です。displayErrorDetails が true の開発時だけ詳細を出し、本番では Internal Server Error に固定して内部情報を伏せます。4xx は Slim や Action が付けた安全なメッセージなので、そのまま返せます。

4. 手組み配線に組み込む

配線は手作業です。依存を手で組み立てる createApp() へ、このハンドラを差し込みます。先に置くのは、品目の値オブジェクトと取得アクションです。

src/Item.php を作成します。

<?php
declare(strict_types=1);

namespace App;

final readonly class Item
{
    public function __construct(
        public string $code,
        public string $name,
        public int $unitPrice,
    ) {
    }

    /**
     * @return array{code: string, name: string, unitPrice: int}
     */
    public function toArray(): array
    {
        return [
            'code' => $this->code,
            'name' => $this->name,
            'unitPrice' => $this->unitPrice,
        ];
    }
}

src/Action/ShowItemAction.php を作成します。該当コードが無ければ HttpNotFoundException を投げ、変換はエラーハンドラに任せる流れです。

<?php
declare(strict_types=1);

namespace App\Action;

use App\Item;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Exception\HttpNotFoundException;

final class ShowItemAction
{
    /**
     * @param array<string, Item> $items
     */
    public function __construct(private array $items)
    {
    }

    /**
     * @param array{code?: string} $args
     */
    public function __invoke(
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $args,
    ): ResponseInterface {
        $code = (string) ($args['code'] ?? '');
        $item = $this->items[$code] ?? null;

        if ($item === null) {
            throw new HttpNotFoundException($request, "Item not found: {$code}");
        }

        $response->getBody()->write(
            (string) json_encode($item->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)
        );

        return $response->withHeader('Content-Type', 'application/json');
    }
}

app/settings.php を作成します。APP_ENV が prod 以外のとき、詳細表示を有効にする設定です。

<?php
declare(strict_types=1);

$appEnv = getenv('APP_ENV') ?: 'dev';

return [
    'displayErrorDetails' => $appEnv !== 'prod',
    'logErrors' => true,
];

app/bootstrap.php を作成します。AppFactory::create() で起動し、setDefaultErrorHandler() には継承ハンドラを渡すだけです。

<?php
declare(strict_types=1);

use App\Action\ShowItemAction;
use App\Handler\JsonErrorHandler;
use App\Item;
use Slim\App;
use Slim\Factory\AppFactory;

function createApp(): App
{
    /** @var array{displayErrorDetails: bool, logErrors: bool} $settings */
    $settings = require __DIR__ . '/settings.php';

    $app = AppFactory::create();

    $app->addRoutingMiddleware();

    $errorMiddleware = $app->addErrorMiddleware(
        $settings['displayErrorDetails'],
        $settings['logErrors'],
        false,
    );
    $errorMiddleware->setDefaultErrorHandler(
        new JsonErrorHandler(
            $app->getCallableResolver(),
            $app->getResponseFactory(),
        )
    );

    $items = [
        'A-1001' => new Item('A-1001', 'M6 六角ボルト', 120),
        'A-1002' => new Item('A-1002', 'M6 平ワッシャー', 18),
    ];

    (require __DIR__ . '/routes.php')($app, new ShowItemAction($items));

    return $app;
}

app/routes.php を作成します。品目取得に加え、もう 1 本は 500 を確かめるための「わざと落ちる」ルートです。

<?php
declare(strict_types=1);

use App\Action\ShowItemAction;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\App;

return static function (App $app, ShowItemAction $showItem): void {
    $app->get('/api/items/{code}', $showItem);

    $app->get('/api/boom', function (
        ServerRequestInterface $request,
        ResponseInterface $response,
    ): ResponseInterface {
        throw new RuntimeException('Boom! (intentional failure for the 500 demo)');
    });
};

public/index.php を作成します。入口はオートロードと配線を読み込み、createApp() を実行するだけです。

<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../app/bootstrap.php';

createApp()->run();

コードのポイント

① 継承ハンドラは callableResolver と responseFactory を受け取る(bootstrap.php)

    $errorMiddleware->setDefaultErrorHandler(
        new JsonErrorHandler(
            $app->getCallableResolver(),
            $app->getResponseFactory(),
        )
    );

ErrorHandler のコンストラクタは CallableResolverInterface と ResponseFactoryInterface を要求します。コンテナなしでも、AppFactory::create() で作った $app から両方を取り出せます。addErrorMiddleware() が返すミドルウェアに setDefaultErrorHandler() をつなぐと、以降の全例外がこのハンドラを通る配線です。

5. curl で確認する

別ターミナルを開き、HTTP サーバーを手動起動します。このコマンドは起動したままになるので、止めるまでそのターミナルは閉じないでください。

docker compose exec app php -S 0.0.0.0:8080 -t public public/index.php

まず正常系と 404 を確かめます。

curl -i http://localhost:8080/api/items/A-1001
curl -i http://localhost:8080/api/items/NOPE

実行結果例:

HTTP/1.1 200 OK
Content-Type: application/json

{"code":"A-1001","name":"M6 六角ボルト","unitPrice":120}
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":{"code":"not_found","message":"Item not found: NOPE"}}

次は、メソッド違いの 405 と未捕捉例外の 500 です。GET だけ定義した品目ルートへ POST し、続けて /api/boom を叩きます。

curl -i -X POST http://localhost:8080/api/items/A-1001
curl -i http://localhost:8080/api/boom

実行結果例:

HTTP/1.1 405 Method Not Allowed
Content-Type: application/json

{"error":{"code":"method_not_allowed","message":"Method not allowed. Must be one of: GET"}}
HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{"error":{"code":"internal_error","message":"Boom! (intentional failure for the 500 demo)"}}

開発時(APP_ENV=dev)は、500 の message に例外の中身が出ます。本番想定で同じ /api/boom を叩くと、詳細は伏せられる挙動です。先の php -S を止め、docker compose exec に -e APP_ENV=prod を付けて起動し直してください。

# 先の php -S を Ctrl+C で止めてから実行する
docker compose exec -e APP_ENV=prod app php -S 0.0.0.0:8080 -t public public/index.php

別ターミナルで叩きます。

curl -i http://localhost:8080/api/boom

実行結果例:

HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{"error":{"code":"internal_error","message":"Internal Server Error"}}

code が状況ごとに変わり、message だけが本番で伏せられていれば成功です。

詰まり時:

  • エラーが HTML で返る: bootstrap.php の setDefaultErrorHandler() を呼べているか見直す
  • 405 にならず 404 になる: ルートのパスとメソッド定義を確認する

6. PHPUnit で形を固定する

エラー形は、一度決めたら崩したくありません。createApp() を起動し、各エラーが同じ形で返るかをテストで固定する方針です。

tests/bootstrap.php を作成します。

<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../app/bootstrap.php';

テストの実行設定は最小限です。phpunit.xml を作成します。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         bootstrap="tests/bootstrap.php"
         colors="true">
    <testsuites>
        <testsuite name="app">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

tests/Http/ErrorShapeTest.php を作成します。404・405・500 を 1 つのヘルパで叩き、code を突き合わせる内容です。

<?php
declare(strict_types=1);

namespace Tests\Http;

use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;

final class ErrorShapeTest extends TestCase
{
    public function testUnknownItemReturnsNotFound(): void
    {
        $result = $this->handle('GET', '/api/items/NOPE');

        self::assertSame(404, $result['status']);
        self::assertSame('not_found', $result['body']['error']['code']);
    }

    public function testWrongMethodReturnsMethodNotAllowed(): void
    {
        $result = $this->handle('POST', '/api/items/A-1001');

        self::assertSame(405, $result['status']);
        self::assertSame('method_not_allowed', $result['body']['error']['code']);
    }

    public function testUncaughtExceptionReturnsInternalError(): void
    {
        $result = $this->handle('GET', '/api/boom');

        self::assertSame(500, $result['status']);
        self::assertSame('internal_error', $result['body']['error']['code']);
    }

    /**
     * @return array{status: int, body: array<string, mixed>}
     */
    private function handle(string $method, string $path): array
    {
        $app = createApp();
        $request = (new ServerRequestFactory())->createServerRequest($method, $path);
        $response = $app->handle($request);

        /** @var array<string, mixed> $body */
        $body = json_decode((string) $response->getBody(), true);

        return ['status' => $response->getStatusCode(), 'body' => $body];
    }
}

テストを実行します。

docker compose exec app ./vendor/bin/phpunit

実行結果例:

PHPUnit 11.5 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 00:00.0xx, Memory: xx.xx MB

OK (3 tests, 6 assertions)

コードのポイント

① 1 つのヘルパで各エラーを叩く(ErrorShapeTest.php)

        $app = createApp();
        $request = (new ServerRequestFactory())->createServerRequest($method, $path);
        $response = $app->handle($request);

$app->handle($request) は、ルーティングからエラーハンドラまでを通す呼び出しです。DB を使わない構成なので、各テストは createApp() を呼ぶだけで起動でき、404・405・500 の code が崩れていないかを 1 形で突き合わせられます。

7. まとめ

この記事で組み立てた内容は、次のとおりです。

  • Slim\Handlers\ErrorHandler を継承し、respond() だけを差し替えてエラーを JSON 化する
  • ステータスコードの振り分けは親クラスに任せ、code と message の形だけを決める
  • APP_ENV で 500 の詳細を出し分け、本番では内部情報を伏せる
  • 404・405・500 が同じ形で返ることを PHPUnit で固定する

エラー形が決まると、次は不正な入力値を同じ仕組みで 400 に変換する話につなげられます。入力検証そのものは別記事のテーマです。

手組み配線そのものの考え方は、同じシリーズの Slim 4を手組み配線でテスト可能にする(DIコンテナなしでHTTPテストを書く) に分けてあります。

シリーズ 5/7

このシリーズ

Slim 4で作るPHP API

  1. 1. Slim Framework 4 を使った業務Webアプリ導入ガイド(PHP 8.2+)
  2. 2. Slim 4で最小JSON APIを作る(routing + DI + error handling)
  3. 3. Slim 4 + PostgreSQLで認証付きREST APIを作る(JWT最小構成)
  4. 4. Slim 4を手組み配線でテスト可能にする(DIコンテナなしでHTTPテストを書く)
  5. 5. Slim 4の例外レスポンスをJSONに統一する(ErrorHandler継承とAPP_ENV出し分け) 現在の記事
  6. 6. 選択肢UIに頼らないSlim APIの入力検証(UseCaseで存在チェックして400に変換)
  7. 7. Slim 4のAPI DTOをfinal readonly classで不変にする(名前付き引数・match・JSON_THROW_ON_ERROR)