公開日 2026-09-17

選択肢UIに頼らないSlim APIの入力検証(UseCaseで存在チェックして400に変換)

フロントの選択肢に頼らず、Slim 4 の API で入力値を検証する。UseCase 層で存在チェックし、専用例外を Action で 400 の JSON に変換する。

目次

  1. 前提環境
  2. 1. 何を防ぐか
  3. 2. デモ環境を用意する
  4. 3. 値オブジェクトと専用例外を用意する
  5. 4. UseCaseで存在チェックする
  6. コードのポイント
  7. 5. Actionで専用例外を400に変換する
  8. コードのポイント
  9. 6. 手組み配線に組み込む
  10. コードのポイント
  11. 7. curl で確認する
  12. 8. PHPUnit で固定する
  13. コードのポイント
  14. 9. まとめ

対象読者: 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 の記事へ続きます。

シリーズ 6/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)