対象読者: Slim 4 で API を作っていて、「フロントで選べない値は来ない」と考えず、サーバー側で入力を検証したい方
この記事では、注文を受け付ける POST /api/orders を題材に、入力値をサーバー側で検証します。存在しない品目コードや 0 以下の数量を、{"error":{"code":"validation_error","message":"..."}} の 400 で弾くのがゴールです。検証は UseCase 層に置き、専用の例外を Action で 400 へ変換します。
題材は、実務で「フロントのプルダウンが正しい値しか出さないから安全」と考えて存在チェックを省き、別経路から入った不正値がそのまま保存された、という失敗を一般化したものです。確認は curl と PHPUnit の 2 本立て。認証と永続化(DB)は扱わず、品目マスタと注文はメモリ上に置く構成です。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL連携有効)
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。アプリ用コンテナのサービス名は app に固定しておきます。
1. 何を防ぐか
フロントが品目をプルダウンで出していると、「不正な品目コードは送られてこない」と考えがちです。ところが API は画面の外からも叩けます。直接の curl、古いキャッシュを持ったクライアント、検証用のツール — どれも、画面が許さない値を送れてしまう経路です。
サーバー側で存在チェックを省くと、その値はそのまま通ります。次のような Action は、品目コードが実在するかを見ていない例です。
// 不十分な例: 存在チェックが無く、不明な品目コードでも 201 を返してしまう
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = (array) ($request->getParsedBody() ?? []);
$order = $this->orders->save((string) $body['itemCode'], (int) $body['quantity']);
return $this->json($response, 201, $order->toArray());
}
このコードは {"itemCode":"ZZZ","quantity":-1} でも保存します。仕上げる挙動は次の 3 点です。
- 実在する品目コードと 1 以上の数量だけを受け付け、
201で注文を返す - 不明な品目コード・0 以下の数量・項目欠落は
400のvalidation_errorで弾く - 検証ロジックは Action ではなく UseCase 層に置き、テストしやすくする
今回の範囲外は次の点です。
- 認証・認可
- 永続化(品目マスタと注文はメモリ上に置く)
- 項目別の詳細エラー(
fieldsなど)。今回はcodeとmessageの単一形にそろえる - バリデーション用ライブラリの導入
2. デモ環境を用意する
WSL 側のシェルで作業します。Windows 側から始める場合は、wsl で Ubuntu に入ってから実行してください。
次のコマンドで作業ディレクトリを用意します。
# Windows側から始める場合のみ実行
# wsl
mkdir -p ~/projects/slim4-input-validation-demo
cd ~/projects/slim4-input-validation-demo
mkdir -p docker/php app public src/Action src/Exception src/Handler src/Repository src/UseCase 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-input-validation-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 サーバーは 7 章で手動起動します。
3. 値オブジェクトと専用例外を用意する
最初に、品目マスタ・注文・検証エラー用の例外を置きます。品目マスタは、存在チェックの突き合わせ先です。
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,
) {
}
}
src/Order.php を作成します。
<?php
declare(strict_types=1);
namespace App;
final readonly class Order
{
public function __construct(
public int $id,
public string $itemCode,
public int $quantity,
) {
}
/**
* @return array{id: int, itemCode: string, quantity: int}
*/
public function toArray(): array
{
return [
'id' => $this->id,
'itemCode' => $this->itemCode,
'quantity' => $this->quantity,
];
}
}
検証の失敗を表すのが次の例外です。src/Exception/ValidationException.php を作成します。
<?php
declare(strict_types=1);
namespace App\Exception;
use RuntimeException;
final class ValidationException extends RuntimeException
{
}
最後に、注文を保存する係です。src/Repository/OrderRepository.php を作成します。ここではメモリ上に積むだけです。
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Order;
final class OrderRepository
{
/** @var list<Order> */
private array $orders = [];
private int $nextId = 1;
public function save(string $itemCode, int $quantity): Order
{
$order = new Order($this->nextId, $itemCode, $quantity);
$this->orders[] = $order;
$this->nextId++;
return $order;
}
}
4. UseCaseで存在チェックする
検証は UseCase 層に集めます。src/UseCase/CreateOrder.php を作成します。品目マスタに無いコードや 1 未満の数量を、ValidationException で弾く形です。
<?php
declare(strict_types=1);
namespace App\UseCase;
use App\Exception\ValidationException;
use App\Item;
use App\Order;
use App\Repository\OrderRepository;
final class CreateOrder
{
/**
* @param array<string, Item> $items
*/
public function __construct(
private array $items,
private OrderRepository $orders,
) {
}
public function create(string $itemCode, int $quantity): Order
{
if ($itemCode === '') {
throw new ValidationException('itemCode is required.');
}
if (!isset($this->items[$itemCode])) {
throw new ValidationException("Unknown item code: {$itemCode}");
}
if ($quantity < 1) {
throw new ValidationException('quantity must be 1 or more.');
}
return $this->orders->save($itemCode, $quantity);
}
}
コードのポイント
① 存在チェックを UseCase に置く(CreateOrder.php)
if (!isset($this->items[$itemCode])) {
throw new ValidationException("Unknown item code: {$itemCode}");
}
品目マスタ($items)への突き合わせを UseCase に置くと、画面の有無に関係なく同じ検証が走ります。HTTP も DB も関与しないため、後の 8 章では CreateOrder を直接呼ぶ単体テストも可能です。エラーは ValidationException に統一し、理由をメッセージへ載せます。
5. Actionで専用例外を400に変換する
Action は HTTP の入出力に専念し、ValidationException を捕まえて 400 へ変換する役です。src/Action/CreateOrderAction.php を作成します。
<?php
declare(strict_types=1);
namespace App\Action;
use App\Exception\ValidationException;
use App\UseCase\CreateOrder;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class CreateOrderAction
{
public function __construct(private CreateOrder $createOrder)
{
}
public function __invoke(
ServerRequestInterface $request,
ResponseInterface $response,
): ResponseInterface {
$body = (array) ($request->getParsedBody() ?? []);
$itemCode = is_string($body['itemCode'] ?? null) ? $body['itemCode'] : '';
$quantity = $this->toInt($body['quantity'] ?? null);
try {
$order = $this->createOrder->create($itemCode, $quantity);
} catch (ValidationException $exception) {
return $this->json($response, 400, [
'error' => [
'code' => 'validation_error',
'message' => $exception->getMessage(),
],
]);
}
return $this->json($response, 201, $order->toArray());
}
private function toInt(mixed $value): int
{
if (is_int($value)) {
return $value;
}
return is_string($value) && ctype_digit($value) ? (int) $value : 0;
}
/**
* @param array<string, mixed> $payload
*/
private function json(ResponseInterface $response, int $status, array $payload): ResponseInterface
{
$response->getBody()->write(
(string) json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)
);
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
}
}
コードのポイント
① 専用例外だけを 400 に変換する(CreateOrderAction.php)
try {
$order = $this->createOrder->create($itemCode, $quantity);
} catch (ValidationException $exception) {
return $this->json($response, 400, [
ValidationException だけを捕まえ、validation_error の 400 にします。これ以外の例外は捕まえないので、想定外の不具合は 500 として表面化する切り分けです(失敗を握りつぶさない)。数値の取り出しは toInt() に寄せ、文字列の数字だけ通して、それ以外は 0 として UseCase の数量チェックに委ねます。
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 を作成します。品目マスタを置き、UseCase と Action を組み立てる関数です。
<?php
declare(strict_types=1);
use App\Action\CreateOrderAction;
use App\Handler\JsonErrorHandler;
use App\Item;
use App\Repository\OrderRepository;
use App\UseCase\CreateOrder;
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(),
)
);
$items = [
'A-1001' => new Item('A-1001', 'M6 六角ボルト', 120),
'A-1002' => new Item('A-1002', 'M6 平ワッシャー', 18),
];
$createOrder = new CreateOrder($items, new OrderRepository());
(require __DIR__ . '/routes.php')($app, new CreateOrderAction($createOrder));
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();
コードのポイント
① addBodyParsingMiddleware() で JSON ボディを読む(bootstrap.php)
$app->addBodyParsingMiddleware();
この 1 行がないと、getParsedBody() は null を返し、itemCode も quantity も取り出せません。POST の JSON を配列として受け取るため、ルーティングより前に置くのが要点です。
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}'
実行結果例:
HTTP/1.1 201 Created
Content-Type: application/json
{"id":1,"itemCode":"A-1001","quantity":2}
次に、不明な品目コードと 0 以下の数量を送ります。どちらも validation_error の 400 です。
curl -i -X POST http://localhost:8080/api/orders \
-H 'Content-Type: application/json' \
-d '{"itemCode":"ZZZ","quantity":2}'
curl -i -X POST http://localhost:8080/api/orders \
-H 'Content-Type: application/json' \
-d '{"itemCode":"A-1001","quantity":0}'
実行結果例:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error":{"code":"validation_error","message":"Unknown item code: ZZZ"}}
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error":{"code":"validation_error","message":"quantity must be 1 or more."}}
画面が ZZZ を選ばせなくても、API へ直接送れば届きます。サーバー側で弾けていれば、不正値は保存されません。
詰まり時:
- ボディが空になる:
Content-Type: application/jsonとaddBodyParsingMiddleware()を確認する - 400 ではなく 500 になる:
CreateOrderActionがValidationExceptionを捕まえているか見直す
8. PHPUnit で固定する
検証ルールは、回帰させたくない部分です。UseCase の単体テストと、POST の HTTP テストを置きます。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/CreateOrderTest.php を作成します。正常系の 201 と、検証失敗の 400 を突き合わせる内容です。
<?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]);
self::assertSame(201, $result['status']);
self::assertSame('A-1001', $result['body']['itemCode']);
}
public function testUnknownItemCodeReturns400(): void
{
$result = $this->postJson(['itemCode' => 'ZZZ', 'quantity' => 2]);
self::assertSame(400, $result['status']);
self::assertSame('validation_error', $result['body']['error']['code']);
}
public function testNonPositiveQuantityReturns400(): void
{
$result = $this->postJson(['itemCode' => 'A-1001', 'quantity' => 0]);
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.
... 3 / 3 (100%)
Time: 00:00.0xx, Memory: xx.xx MB
OK (3 tests, 6 assertions)
コードのポイント
① withParsedBody() で本文を渡す(CreateOrderTest.php)
$request = (new ServerRequestFactory())
->createServerRequest('POST', '/api/orders')
->withHeader('Content-Type', 'application/json')
->withParsedBody($body)
->withBody($stream);
テストでは body parsing ミドルウェアを通さないため、withParsedBody() で配列を直接渡すのがコツです。これで getParsedBody() が期待どおりに配列を返し、Action から UseCase までを HTTP 経由で確かめられます。
9. まとめ
この記事で組み立てた内容は、次のとおりです。
- 「フロントで選べない値は来ない」という前提を外し、サーバー側で検証する
- 存在チェックを UseCase 層に置き、HTTP も DB も無しで検証を単体テストできる
- 専用例外
ValidationExceptionを Action でvalidation_errorの 400 へ変換する - 正常系 201 と検証失敗 400 を PHPUnit で固定する
validation_error のエラー形は、Slim 4の例外レスポンスをJSONに統一する(ErrorHandler継承とAPP_ENV出し分け) で決めた {"error":{"code","message"}} にそろえてあります。UseCase へ渡す値オブジェクトを不変にする話は、次の DTO の記事へ続きます。