公開日 2026-09-19

Slim 4のAPI DTOをfinal readonly classで不変にする(名前付き引数・match・JSON_THROW_ON_ERROR)

Slim 4 の API で、リクエストとレスポンスを final readonly class の DTO にする。名前付き引数とコンストラクタプロモーションで組み立て、match で検証と変換を行い、JSON_THROW_ON_ERROR で返す。

目次

  1. 前提環境
  2. 1. 作る DTO と方針
  3. enum と first-class callable を扱わない理由
  4. 2. デモ環境を用意する
  5. 3. リクエストを final readonly の DTO で受ける
  6. コードのポイント
  7. 4. レスポンスを final readonly の DTO で返す
  8. コードのポイント
  9. 5. Actionで DTO を組み立てる
  10. コードのポイント
  11. 6. 手組み配線に組み込む
  12. 7. curl で確認する
  13. 8. PHPUnit で固定する
  14. コードのポイント
  15. 9. まとめ

対象読者: Slim 4 で API を作っていて、リクエストとレスポンスを可変な配列で回さず、不変の DTO で扱いたい方

この記事では、POST /api/orders を題材に、リクエストとレスポンスを final readonly class の DTO にします。生の連想配列を引き回す代わりに、入口で 1 度だけ検証して不変の値へ固め、その先は書き換えられない前提で扱うのが狙いです。組み立ては名前付き引数とコンストラクタプロモーション、priority の検証と変換は match(default で throw)、文字列化は JSON_THROW_ON_ERROR で行います。

enum と first-class callable は扱いません(理由は 1 章)。確認は curl と PHPUnit の 2 本立てで、永続化(DB)は範囲外です。

前提環境

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

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

1. 作る DTO と方針

注文の作成で通すのは、2 つの DTO です。

  • 入口の CreateOrderRequest: 受け取った JSON を検証し、不変の値へ固める
  • 出口の OrderResponse: 返す内容をまとめ、JSON_THROW_ON_ERROR で文字列化する

仕上げる挙動は次の 3 点です。

  • リクエストを CreateOrderRequest に変換し、後から書き換えられない値として扱う
  • priority を match で検証・変換し、未知の値は default で弾く
  • レスポンスを OrderResponse にまとめ、JSON_THROW_ON_ERROR で文字列化する

今回の範囲外も挙げておきます。

  • 認証・認可
  • 永続化(注文は採番だけして保存しない)
  • 項目別の詳細エラー(code / message の単一形にそろえる)
  • enum と first-class callable

enum と first-class callable を扱わない理由

priority のような固定集合は enum が自然な選択肢になります。この記事で match を採るのは、「JSON から来た文字列をその場で検証し、同時に重みへ変換する」流れを 1 か所で見せたいからです。enum を挟むと tryFrom() と null の処理が別途必要になり、今回の主題(DTO の組み立てと検証)から焦点がぶれます。enum は固定集合をコードで表す有力な手段で、別記事で単独に扱う予定です。first-class callable も今回の DTO では使わないため、取り上げません。

検証をどの層へ置くかという観点は、選択肢UIに頼らないSlim APIの入力検証(UseCaseで存在チェックして400に変換) に分けてあります。扱うのは、その値を「不変の DTO としてどう組み立てるか」だけです。

2. デモ環境を用意する

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

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

# Windows側から始める場合のみ実行
# wsl
mkdir -p ~/projects/slim4-api-dto-final-readonly-demo
cd ~/projects/slim4-api-dto-final-readonly-demo
mkdir -p docker/php app public src/Action src/Dto src/Exception src/Handler tests/Dto 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-api-dto-final-readonly-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 サーバーは 6 章で手動起動します。

3. リクエストを final readonly の DTO で受ける

入口の DTO を作ります。先に、検証の失敗時に投げる例外です。src/Exception/ValidationException.php を作成します。

<?php
declare(strict_types=1);

namespace App\Exception;

use RuntimeException;

final class ValidationException extends RuntimeException
{
}

src/Dto/CreateOrderRequest.php を作成します。コンストラクタはプロモーションで宣言と代入をまとめ、fromArray() が検証してから名前付き引数で生成する形です。

<?php
declare(strict_types=1);

namespace App\Dto;

use App\Exception\ValidationException;

final readonly class CreateOrderRequest
{
    public function __construct(
        public string $itemCode,
        public int $quantity,
        public string $priority,
        public int $priorityWeight,
    ) {
    }

    /**
     * @param array<string, mixed> $body
     */
    public static function fromArray(array $body): self
    {
        $itemCode = is_string($body['itemCode'] ?? null) ? $body['itemCode'] : '';
        if ($itemCode === '') {
            throw new ValidationException('itemCode is required.');
        }

        $quantity = is_int($body['quantity'] ?? null) ? $body['quantity'] : 0;
        if ($quantity < 1) {
            throw new ValidationException('quantity must be 1 or more.');
        }

        $priority = is_string($body['priority'] ?? null) ? $body['priority'] : '';
        $priorityWeight = match ($priority) {
            'low' => 1,
            'normal' => 5,
            'high' => 10,
            default => throw new ValidationException("Unknown priority: {$priority}"),
        };

        return new self(
            itemCode: $itemCode,
            quantity: $quantity,
            priority: $priority,
            priorityWeight: $priorityWeight,
        );
    }
}

コードのポイント

① final readonly で「作った後は変えない」を型で示す(CreateOrderRequest.php)

final readonly class CreateOrderRequest
{
    public function __construct(
        public string $itemCode,

final readonly class にすると、全プロパティが読み取り専用になり、生成後の代入はできません。書き換えようとすると Error: Cannot modify readonly property が出る仕組みです(8 章のテストで確かめます)。コンストラクタプロモーション(public string $itemCode など)で、宣言・代入・公開を 1 か所にまとめています。

② match で検証と変換を同時に行い、未知は default で弾く(CreateOrderRequest.php)

        $priorityWeight = match ($priority) {
            'low' => 1,
            'normal' => 5,
            'high' => 10,
            default => throw new ValidationException("Unknown priority: {$priority}"),
        };

match は値を厳密(===)に突き合わせます。low / normal / high を重みへ変換しつつ、どれにも当たらない値は default で ValidationException を投げる形です。switch と違って match の default を書かないと未知の値で UnhandledMatchError になるため、ここで明示的に検証エラーへ寄せています。

4. レスポンスを final readonly の DTO で返す

出口も DTO です。src/Dto/OrderResponse.php を作成します。toJson() は JSON_THROW_ON_ERROR を付け、失敗をその場で握りつぶしません。

<?php
declare(strict_types=1);

namespace App\Dto;

final readonly class OrderResponse
{
    public function __construct(
        public int $id,
        public string $itemCode,
        public int $quantity,
        public string $priority,
    ) {
    }

    public function toJson(): string
    {
        return (string) json_encode(
            [
                'id' => $this->id,
                'itemCode' => $this->itemCode,
                'quantity' => $this->quantity,
                'priority' => $this->priority,
            ],
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE,
        );
    }
}

コードのポイント

① JSON_THROW_ON_ERROR で失敗を例外にする(OrderResponse.php)

        return (string) json_encode(
            [
                'id' => $this->id,

json_encode() は既定だと失敗時に false を返すだけなので、戻り値を見落とすと壊れた応答になりがちです。JSON_THROW_ON_ERROR を付けると失敗が JsonException になり、捕まえなければ 500 として表面化します。DTO の値は事前に検証済みなので、ここでは握りつぶさず、想定外だけを例外に任せています。

5. Actionで DTO を組み立てる

Action は、ボディから入口 DTO を作り、出口 DTO を返す役です。src/Action/CreateOrderAction.php を作成します。検証失敗の ValidationException は validation_error の 400 へ変換します。

<?php
declare(strict_types=1);

namespace App\Action;

use App\Dto\CreateOrderRequest;
use App\Dto\OrderResponse;
use App\Exception\ValidationException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class CreateOrderAction
{
    public function __invoke(
        ServerRequestInterface $request,
        ResponseInterface $response,
    ): ResponseInterface {
        $body = (array) ($request->getParsedBody() ?? []);

        try {
            $order = CreateOrderRequest::fromArray($body);
        } catch (ValidationException $exception) {
            $response->getBody()->write(
                (string) json_encode(
                    ['error' => ['code' => 'validation_error', 'message' => $exception->getMessage()]],
                    JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE,
                )
            );

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

        $created = new OrderResponse(
            id: 1,
            itemCode: $order->itemCode,
            quantity: $order->quantity,
            priority: $order->priority,
        );

        $response->getBody()->write($created->toJson());

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

コードのポイント

① 配列ではなく DTO を引き回す(CreateOrderAction.php)

        $order = CreateOrderRequest::fromArray($body);
        // ...
        $created = new OrderResponse(
            id: 1,
            itemCode: $order->itemCode,

fromArray() を抜けた後の $order は、検証済みで型の付いた不変値です。$order->itemCode のようにプロパティで読めるので、$body['itemcode'] のような綴り違いや欠落を後段で気にせずに済みます。出口も OrderResponse を名前付き引数で組み立て、返す形は 1 か所に固定です。

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

配線は手作業です。依存を手で組み立てる createApp() にまとめ、POST のボディを読むため addBodyParsingMiddleware() を加えます。404・500 などは JSON で返したいので、src/Handler/JsonErrorHandler.php を作成します。これは Slim 4の例外レスポンスをJSONに統一する(ErrorHandler継承とAPP_ENV出し分け) と同じ共通ハンドラです。

<?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 === 404 => 'not_found',
            $status === 405 => 'method_not_allowed',
            $status >= 500 => 'internal_error',
            default => 'error',
        };

        $message = $status >= 500 && !$this->displayErrorDetails
            ? 'Internal Server Error'
            : $this->exception->getMessage();

        $payload = ['error' => ['code' => $code, 'message' => $message]];

        $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');
    }
}

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 を作成します。アクションは依存を持たないので、そのまま組み立てる関数です。

<?php
declare(strict_types=1);

use App\Action\CreateOrderAction;
use App\Handler\JsonErrorHandler;
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->addBodyParsingMiddleware();
    $app->addRoutingMiddleware();

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

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

    return $app;
}

app/routes.php を作成します。注文の作成は POST /api/orders で受ける 1 本です。

<?php
declare(strict_types=1);

use App\Action\CreateOrderAction;
use Slim\App;

return static function (App $app, CreateOrderAction $createOrder): void {
    $app->post('/api/orders', $createOrder);
};

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

<?php
declare(strict_types=1);

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

createApp()->run();

7. curl で確認する

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

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

まず正常な注文を送ります。

curl -i -X POST http://localhost:8080/api/orders \
  -H 'Content-Type: application/json' \
  -d '{"itemCode":"A-1001","quantity":2,"priority":"high"}'

実行結果例:

HTTP/1.1 201 Created
Content-Type: application/json

{"id":1,"itemCode":"A-1001","quantity":2,"priority":"high"}

次に、未知の priority を送ります。match の default が ValidationException を投げ、400 です。

curl -i -X POST http://localhost:8080/api/orders \
  -H 'Content-Type: application/json' \
  -d '{"itemCode":"A-1001","quantity":2,"priority":"urgent"}'

実行結果例:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{"error":{"code":"validation_error","message":"Unknown priority: urgent"}}

priority の取りうる値が増えても、match に 1 行足すだけで済みます。未知の値を弾く経路は同じままです。

詰まり時:

  • ボディが空になる: Content-Type: application/json と addBodyParsingMiddleware() を確認する
  • 未知 priority で 500 になる: match に default => throw ... があるかを見直す

8. PHPUnit で固定する

DTO の振る舞いを 2 つの面から固定します。1 つは fromArray() の検証と不変性、もう 1 つは HTTP 経由の 201 と 400 です。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>

まず DTO 単体の確認です。tests/Dto/CreateOrderRequestTest.php を作成します。正常な変換・未知 priority の拒否・読み取り専用の 3 点を見ます。

<?php
declare(strict_types=1);

namespace Tests\Dto;

use App\Dto\CreateOrderRequest;
use App\Exception\ValidationException;
use Error;
use PHPUnit\Framework\TestCase;

final class CreateOrderRequestTest extends TestCase
{
    public function testFromArrayMapsPriorityToWeight(): void
    {
        $request = CreateOrderRequest::fromArray([
            'itemCode' => 'A-1001',
            'quantity' => 2,
            'priority' => 'high',
        ]);

        self::assertSame('A-1001', $request->itemCode);
        self::assertSame(10, $request->priorityWeight);
    }

    public function testUnknownPriorityIsRejected(): void
    {
        $this->expectException(ValidationException::class);

        CreateOrderRequest::fromArray([
            'itemCode' => 'A-1001',
            'quantity' => 2,
            'priority' => 'urgent',
        ]);
    }

    public function testPropertiesAreReadonly(): void
    {
        $request = CreateOrderRequest::fromArray([
            'itemCode' => 'A-1001',
            'quantity' => 2,
            'priority' => 'low',
        ]);

        $this->expectException(Error::class);
        /** @phpstan-ignore-next-line readonly プロパティへの代入を意図的に試す */
        $request->itemCode = 'B-2002';
    }
}

次に HTTP 経由のテストです。tests/Http/CreateOrderTest.php を作成します。

<?php
declare(strict_types=1);

namespace Tests\Http;

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

final class CreateOrderTest extends TestCase
{
    public function testValidOrderReturns201(): void
    {
        $result = $this->postJson(['itemCode' => 'A-1001', 'quantity' => 2, 'priority' => 'high']);

        self::assertSame(201, $result['status']);
        self::assertSame('high', $result['body']['priority']);
    }

    public function testUnknownPriorityReturns400(): void
    {
        $result = $this->postJson(['itemCode' => 'A-1001', 'quantity' => 2, 'priority' => 'urgent']);

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

    /**
     * @param array<string, mixed> $body
     * @return array{status: int, body: array<string, mixed>}
     */
    private function postJson(array $body): array
    {
        $app = createApp();

        $stream = (new StreamFactory())
            ->createStream((string) json_encode($body, JSON_THROW_ON_ERROR));
        $request = (new ServerRequestFactory())
            ->createServerRequest('POST', '/api/orders')
            ->withHeader('Content-Type', 'application/json')
            ->withParsedBody($body)
            ->withBody($stream);

        $response = $app->handle($request);

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

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

テストを実行します。

docker compose exec app ./vendor/bin/phpunit

実行結果例:

PHPUnit 11.5 by Sebastian Bergmann and contributors.

.....                                                               5 / 5 (100%)

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

OK (5 tests, 8 assertions)

コードのポイント

① readonly の不変性をテストで示す(CreateOrderRequestTest.php)

        $this->expectException(Error::class);
        /** @phpstan-ignore-next-line readonly プロパティへの代入を意図的に試す */
        $request->itemCode = 'B-2002';

生成後に itemCode へ代入すると、PHP は Error: Cannot modify readonly property を投げる仕様です。final readonly が「作った後は変えない」を実行時に保証していることを、このテストで固定します。

9. まとめ

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

  • リクエストとレスポンスを final readonly class の DTO にし、生成後は書き換えない
  • 名前付き引数とコンストラクタプロモーションで、組み立てと宣言を 1 か所にまとめる
  • match で priority を検証・変換し、未知は default で 400 へ寄せる
  • JSON_THROW_ON_ERROR で文字列化し、失敗を握りつぶさない

enum での固定集合の表し方は扱いませんでした。別記事で単独に取り上げます。検証をどの層へ置くかは 選択肢UIに頼らないSlim APIの入力検証(UseCaseで存在チェックして400に変換) を参照してください。

シリーズ 7/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) 現在の記事