対象読者: 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に変換) を参照してください。