対象読者: 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テストを書く) に分けてあります。