対象読者: Slim 4 の基本ルーティングは触ったことがあり、DI コンテナを入れずにテストしやすい構成を組みたい方
この記事では、品目コードを 1 件照会する小さな JSON API を Slim 4 で作ります。DI コンテナ(PSR-11)は使わず、依存を手で組み立てる関数 createApp() を 1 つだけ置く構成です。本番ではこの関数が本物のリポジトリを生成し、テストでは同じ関数へ偽物のリポジトリを渡します。入口の引数が違うだけで、DB を立てなくてもアプリ全体の HTTP 応答を PHPUnit から検証できる、というのが狙いです。
確認は curl と PHPUnit の 2 本立てです。認証、複数テーブル、ORM、CI 連携は扱いません。題材は、実務で書いたハンディ端末向け API から配線とテストの要点だけを取り出し、品目照会という一般的な形へ置き換えたものです。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL連携有効)
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。アプリ用コンテナのサービス名は app、PostgreSQL は db に固定しておきます。
1. 作るものと配線の全体像
完成形は次の 4 点です。
GET /healthが DB に触れずに{"status":"ok"}を返すGET /api/items/{code}が PostgreSQL から品目を引き、JSON で応答する- 該当なしや存在しないパスは、HTML ではなく JSON エラーになる
- 同じ
createApp()に偽リポジトリを渡し、DB なしで上記の応答を PHPUnit から確認できる
今回の範囲外も先に切っておきます。
- 認証・認可(前提にしない)
- 一覧・登録・更新(GET 1 本に絞る)
- ORM やマイグレーションツール(テーブルは SQL を直接流す)
- GitHub Actions などの CI 連携
コンテナを入れない理由
DI コンテナを入れると、依存の組み立てがコンテナの定義ファイルへ移ります。小〜中規模の API では、その定義を読み解くより、依存を new で並べた 1 つの関数を見るほうが全体を追いやすいです。テストの差し替えも、コンテナの定義を上書きする代わりに、関数の引数へ偽実装を渡すだけで済みます。
依存が増えて共有インスタンスが多くなる規模なら、コンテナの自動解決が配線の手間を減らします。今回のように依存が数個で収まるうちは、手で並べたほうが見通しがよい、という判断です。その「1 つの関数」が createApp() にあたります。本番経路とテスト経路は、入口の引数が違うだけで、ルーティングもミドルウェアも同じ道を通ります。
flowchart LR
subgraph prod["本番経路 public/index.php"]
A1["createApp()"] --> A2["PdoItemRepository"]
A2 --> A3[("PostgreSQL")]
end
subgraph test["テスト経路 PHPUnit"]
B1["createApp(fake)"] --> B2["InMemoryItemRepository"]
end
A1 --> C["同じ Slim App / 同じ routes.php"]
B1 --> C
C --> D["JSON レスポンス"]
最終的なファイル構成は次の形です。
slim4-testable-manual-wiring-demo/
├─ app/
│ ├─ bootstrap.php # createApp() を定義
│ ├─ routes.php # ルート定義を返すクロージャ
│ └─ settings.php # 環境変数から設定を組む
├─ public/
│ └─ index.php # 本番の入口
├─ src/
│ ├─ Item.php
│ ├─ Action/
│ ├─ Database/
│ ├─ Handler/
│ └─ Repository/
├─ tests/
│ ├─ Fake/
│ ├─ Http/
│ └─ bootstrap.php
├─ docker/php/Dockerfile
├─ compose.yml
├─ composer.json
└─ phpunit.xml
2. デモ環境を作成して起動する
WSL 側のシェルで作業します。Windows 側から始める場合は、wsl で Ubuntu に入ってから実行してください。
次のコマンドで作業ディレクトリを用意します。
# Windows側から始める場合のみ実行
# wsl
mkdir -p ~/projects/slim4-testable-manual-wiring-demo
cd ~/projects/slim4-testable-manual-wiring-demo
mkdir -p docker/php app public src/Action src/Database src/Handler src/Repository tests/Fake tests/Http
code .
compose.yml を作成します。app には DB 接続値を environment で渡し、コードへの直書きを避けます。db 側の healthcheck は、初回接続エラーを抑えるためのものです。
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
working_dir: /workspace
volumes:
- ./:/workspace
ports:
- "8080:8080"
environment:
APP_ENV: dev
DB_DSN: "pgsql:host=db;port=5432;dbname=app"
DB_USER: app
DB_PASSWORD: app
command: ["sleep", "infinity"]
db:
image: postgres:17
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: app
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 20
docker/php/Dockerfile を作成します。本物のリポジトリが PostgreSQL へ接続するため、拡張 pdo_pgsql が必要です。Composer がアーカイブを展開できるよう、unzip も入れておきます。
FROM php:8.5-cli
RUN apt-get update \
&& apt-get install -y --no-install-recommends unzip libpq-dev \
&& docker-php-ext-install pdo_pgsql \
&& 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-testable-manual-wiring-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
DB が起動したら、品目テーブルを作成して 2 件だけ投入します。psql をホストに入れていなくても、コンテナ内から実行できます。
docker compose exec -T db psql -U app -d app \
-c "CREATE TABLE IF NOT EXISTS items (code TEXT PRIMARY KEY, name TEXT NOT NULL, unit_price INTEGER NOT NULL);"
docker compose exec -T db psql -U app -d app \
-c "INSERT INTO items (code, name, unit_price) VALUES ('A-1001', 'M6 六角ボルト', 120), ('A-1002', 'M6 平ワッシャー', 18) ON CONFLICT (code) DO NOTHING;"
ここまでで下準備は完了です。PHP・Composer・PostgreSQL とテーブルがそろいました。HTTP サーバーは 6 章で手動起動します。
詰まり時:
pdo_pgsqlのビルドで失敗する:libpq-devの行が抜けていないか確認し、docker compose up -d --buildをやり直すpsqlがcould not connectを返す:docker compose psでdbが healthy か確認してから再実行する
3. ドメインとリポジトリを用意する
最初に置くのは、品目を表す値オブジェクトと、取得口になるインターフェースです。本番もテストも、この ItemRepository 越しに品目へアクセスします。
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,
];
}
}
取得口は findByCode の 1 メソッドだけです。src/Repository/ItemRepository.php を作成します。
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Item;
interface ItemRepository
{
public function findByCode(string $code): ?Item;
}
次は、PDO 接続を作る係です。src/Database/ConnectionFactory.php を作成します。
<?php
declare(strict_types=1);
namespace App\Database;
use PDO;
final class ConnectionFactory
{
private ?PDO $connection = null;
/**
* @param array{dsn: string, user: string, password: string} $config
*/
public function __construct(private array $config)
{
}
public function connect(): PDO
{
return $this->connection ??= $this->create();
}
private function create(): PDO
{
$pdo = new PDO(
$this->config['dsn'],
$this->config['user'],
$this->config['password'],
);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
return $pdo;
}
}
コードのポイント
① 接続は connect() を最初に呼んだときだけ張る(ConnectionFactory.php)
public function connect(): PDO
{
return $this->connection ??= $this->create();
}
$this->connection ??= $this->create() は、connection が null のときだけ create() を呼び、結果を保持します。ConnectionFactory を組み立てた時点では、接続はまだ張られていない状態です。実際に SQL を投げる経路に入って初めて接続が走るので、DB を使わない GET /health は接続なしで応答できます(6 章で確認します)。
続いて、このファクトリ越しに SQL を投げる本物のリポジトリです。src/Repository/PdoItemRepository.php を作成します。
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Database\ConnectionFactory;
use App\Item;
final class PdoItemRepository implements ItemRepository
{
public function __construct(private ConnectionFactory $connectionFactory)
{
}
public function findByCode(string $code): ?Item
{
$pdo = $this->connectionFactory->connect();
$stmt = $pdo->prepare(
'SELECT code, name, unit_price FROM items WHERE code = :code'
);
$stmt->execute(['code' => $code]);
$row = $stmt->fetch();
if ($row === false) {
return null;
}
return new Item($row['code'], $row['name'], (int) $row['unit_price']);
}
}
4. アクションとJSONエラーを用意する
HTTP の入口になるアクションを 2 つ作ります。HealthAction は DB に触れず、ShowItemAction はリポジトリだけに依存する 2 本です。
src/Action/HealthAction.php を作成します。
<?php
declare(strict_types=1);
namespace App\Action;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class HealthAction
{
public function __invoke(
ServerRequestInterface $request,
ResponseInterface $response,
): ResponseInterface {
$response->getBody()->write(
(string) json_encode(['status' => 'ok'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)
);
return $response->withHeader('Content-Type', 'application/json');
}
}
src/Action/ShowItemAction.php を作成します。該当する品目がなければ HttpNotFoundException を投げ、404 への変換はエラーハンドラに任せる流れです。
<?php
declare(strict_types=1);
namespace App\Action;
use App\Repository\ItemRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Exception\HttpNotFoundException;
final class ShowItemAction
{
public function __construct(private ItemRepository $items)
{
}
/**
* @param array{code?: string} $args
*/
public function __invoke(
ServerRequestInterface $request,
ResponseInterface $response,
array $args,
): ResponseInterface {
$code = (string) ($args['code'] ?? '');
$item = $this->items->findByCode($code);
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');
}
}
失敗時の応答を JSON へそろえるため、src/Handler/JsonErrorHandler.php を作成します。
<?php
declare(strict_types=1);
namespace App\Handler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Factory\ResponseFactory;
use Throwable;
final class JsonErrorHandler
{
public function __construct(private ResponseFactory $responseFactory)
{
}
public function __invoke(
ServerRequestInterface $request,
Throwable $exception,
bool $displayErrorDetails,
bool $logErrors,
bool $logErrorDetails,
): ResponseInterface {
$status = 500;
$message = 'Internal Server Error';
if ($exception instanceof HttpNotFoundException) {
$status = 404;
$message = $exception->getMessage();
} elseif ($exception instanceof HttpBadRequestException) {
$status = 400;
$message = $exception->getMessage();
} elseif ($exception instanceof HttpMethodNotAllowedException) {
$status = 405;
$message = 'Method Not Allowed';
} elseif ($displayErrorDetails) {
$message = $exception->getMessage();
}
$response = $this->responseFactory->createResponse($status);
$response->getBody()->write(
(string) json_encode(
['error' => ['status' => $status, 'message' => $message]],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
)
);
return $response->withHeader('Content-Type', 'application/json');
}
}
コードのポイント
① Slim の HTTP 例外はステータスへ、それ以外は 500 へ(JsonErrorHandler.php)
if ($exception instanceof HttpNotFoundException) {
$status = 404;
$message = $exception->getMessage();
} elseif ($exception instanceof HttpBadRequestException) {
$status = 400;
$message = $exception->getMessage();
} elseif ($exception instanceof HttpMethodNotAllowedException) {
$status = 405;
$message = 'Method Not Allowed';
} elseif ($displayErrorDetails) {
$message = $exception->getMessage();
}
ShowItemAction が投げる HttpNotFoundException は 404 に振り分けます。DB ダウン時の PDOException のように Slim の HTTP 例外でないものは、500 のままです。displayErrorDetails が true のときだけ詳細メッセージを足し、本番(false)では内部情報を伏せます。
5. 手組み配線でアプリを組み立てる
ここがこの記事の中心です。設定の読み込み、createApp() での配線、ルート定義の 3 ファイルを置きます。
app/settings.php を作成します。接続値とエラー表示の方針は、環境変数から読み込む形です。
<?php
declare(strict_types=1);
$appEnv = getenv('APP_ENV') ?: 'dev';
return [
'displayErrorDetails' => $appEnv !== 'prod',
'logErrors' => true,
'db' => [
'dsn' => getenv('DB_DSN') ?: 'pgsql:host=db;port=5432;dbname=app',
'user' => getenv('DB_USER') ?: 'app',
'password' => getenv('DB_PASSWORD') ?: 'app',
],
];
app/bootstrap.php を作成します。依存を手で組み立て、組み上がった App を返す関数を 1 つ置くだけです。
<?php
declare(strict_types=1);
use App\Action\HealthAction;
use App\Action\ShowItemAction;
use App\Database\ConnectionFactory;
use App\Handler\JsonErrorHandler;
use App\Repository\ItemRepository;
use App\Repository\PdoItemRepository;
use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Psr7\Factory\ResponseFactory;
function createApp(?ItemRepository $itemRepository = null): App
{
/** @var array{displayErrorDetails: bool, logErrors: bool, db: array{dsn: string, user: string, password: string}} $settings */
$settings = require __DIR__ . '/settings.php';
$app = AppFactory::create();
$app->addRoutingMiddleware();
$errorMiddleware = $app->addErrorMiddleware(
$settings['displayErrorDetails'],
$settings['logErrors'],
false,
);
$errorMiddleware->setDefaultErrorHandler(
new JsonErrorHandler(new ResponseFactory())
);
$itemRepository ??= new PdoItemRepository(
new ConnectionFactory($settings['db'])
);
$health = new HealthAction();
$showItem = new ShowItemAction($itemRepository);
(require __DIR__ . '/routes.php')($app, $health, $showItem);
return $app;
}
app/routes.php を作成します。App と組み立て済みのアクションを受け取り、URL とハンドラの対応だけを書く構成です。
<?php
declare(strict_types=1);
use App\Action\HealthAction;
use App\Action\ShowItemAction;
use Slim\App;
return static function (
App $app,
HealthAction $health,
ShowItemAction $showItem,
): void {
$app->get('/health', $health);
$app->get('/api/items/{code}', $showItem);
};
public/index.php を作成します。本番の入口は、オートロードと配線を読み込み、createApp() を実行するだけの薄い 1 枚です。
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../app/bootstrap.php';
createApp()->run();
コードのポイント
① AppFactory::create() でコンテナなしに起動する(bootstrap.php)
$app = AppFactory::create();
AppFactory::create() は、slim/psr7 があれば PSR-17 ファクトリを自動検出します。コンテナを接続する createFromContainer() は今回使いません。依存はこのあと手で並べるので、コンテナ定義ファイルは不要です。
② 引数が空なら本番実装、渡されたらそれを使う(bootstrap.php)
$itemRepository ??= new PdoItemRepository(
new ConnectionFactory($settings['db'])
);
$itemRepository ??= ... が、本番経路とテスト経路の分岐点です。引数なしで呼ぶと本物の PdoItemRepository を組み立て、偽実装を渡して呼ぶとそちらを使います。偽実装を渡したときは ConnectionFactory も new されないので、テスト経路は PDO に一切触れない構成です。
③ ルートにはクラス名ではなく実体を渡す(routes.php)
$app->get('/health', $health);
$app->get('/api/items/{code}', $showItem);
コンテナがある構成では、ShowItemAction::class のようにクラス名を渡し、解決をコンテナへ任せます。コンテナを持たない今回は、5 章で new 済みのアクション実体をそのまま渡します。Slim は呼び出し可能オブジェクトをそのまま実行するので、依存つきのアクションでも追加の解決は不要です。
6. curl で本番経路を確認する
別ターミナルを開き、HTTP サーバーを手動起動します。このコマンドは起動したままになるので、止めるまでそのターミナルは閉じないでください。
docker compose exec app php -S 0.0.0.0:8080 -t public public/index.php
最初に GET /health を確認します。
curl -i http://localhost:8080/health
実行結果例:
HTTP/1.1 200 OK
Content-Type: application/json
{"status":"ok"}
次に GET /api/items/{code} を、登録済みコードと未登録コードの両方で叩いてください。
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":{"status":404,"message":"Item not found: NOPE"}}
DBを止めても /health は 200 のままになる
遅延接続の挙動を確かめます。DB だけ止めて、docker compose stop db を実行してから 2 本を比べてください。
docker compose stop db
curl -i http://localhost:8080/health
curl -i http://localhost:8080/api/items/A-1001
GET /health はリポジトリに触れないため、接続が張られず 200 のまま返ります。GET /api/items/{code} はリポジトリが接続を試みるので、PDOException から 500 応答です。
HTTP/1.1 200 OK
Content-Type: application/json
{"status":"ok"}
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{"error":{"status":500,"message":"..."}}
message には、displayErrorDetails が true の開発時のみ SQLSTATE を含む接続エラーが入ります。本番(false)では Internal Server Error です。
/health を「プロセスが生きているか」、品目取得を「DB まで届くか」と読み替えると、監視の対象を切り分けられます。ロードバランサの死活監視は /health だけを見ればよく、DB の一時不通でアプリ全体が落ちたとは扱われません。
確認が済んだら DB を戻してください。次のテストは DB を使わないので、止めたままでも進められます。
docker compose start db
詰まり時:
/api/items/A-1001が 404 になる: 2 章のテーブル作成と投入が済んでいるか確認する- 例外時に HTML が返る:
bootstrap.phpのsetDefaultErrorHandler()を呼べているか見直す
7. DBなしでHTTPテストを書く
ここで、手組み配線にしておいた利点が出ます。createApp() に偽リポジトリを渡し、アプリ全体を起動して HTTP 応答を検証する流れです。DB は使いません。
テスト起動時にもオートロードと配線を読み込むため、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>
続いて ItemRepository の偽実装です。tests/Fake/InMemoryItemRepository.php を作成します。
<?php
declare(strict_types=1);
namespace Tests\Fake;
use App\Item;
use App\Repository\ItemRepository;
final class InMemoryItemRepository implements ItemRepository
{
/** @var array<string, Item> */
private array $items = [];
/**
* @param list<Item> $items
*/
public function __construct(array $items = [])
{
foreach ($items as $item) {
$this->items[$item->code] = $item;
}
}
public function findByCode(string $code): ?Item
{
return $this->items[$code] ?? null;
}
}
いよいよ、アプリ全体を起動して応答を確かめるテストです。tests/Http/ShowItemTest.php を作成します。
<?php
declare(strict_types=1);
namespace Tests\Http;
use App\Item;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
use Tests\Fake\InMemoryItemRepository;
final class ShowItemTest extends TestCase
{
public function testReturnsItemAsJson(): void
{
$repository = new InMemoryItemRepository([
new Item('A-1001', 'M6 六角ボルト', 120),
]);
$app = createApp($repository);
$request = (new ServerRequestFactory())
->createServerRequest('GET', '/api/items/A-1001');
$response = $app->handle($request);
self::assertSame(200, $response->getStatusCode());
/** @var array{code: string, name: string, unitPrice: int} $payload */
$payload = json_decode((string) $response->getBody(), true);
self::assertSame('A-1001', $payload['code']);
self::assertSame('M6 六角ボルト', $payload['name']);
self::assertSame(120, $payload['unitPrice']);
}
public function testReturns404WhenItemMissing(): void
{
$app = createApp(new InMemoryItemRepository());
$request = (new ServerRequestFactory())
->createServerRequest('GET', '/api/items/NOPE');
$response = $app->handle($request);
self::assertSame(404, $response->getStatusCode());
/** @var array{error: array{status: int}} $payload */
$payload = json_decode((string) $response->getBody(), true);
self::assertSame(404, $payload['error']['status']);
}
}
テストを実行します。
docker compose exec app ./vendor/bin/phpunit
実行結果例:
PHPUnit 11.5 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 00:00.0xx, Memory: xx.xx MB
OK (2 tests, 6 assertions)
コードのポイント
① テストも本番と同じ配線を通す(ShowItemTest.php)
$app = createApp($repository);
$response = $app->handle($request);
createApp($repository) は、5 章で本番が呼ぶ関数そのものです。違いは、偽リポジトリを渡す 1 点だけです。$app->handle($request) はルーティング・アクション・エラーハンドラを順に通すので、ルート定義や 404 の振る舞いまで含めて 1 つのテストで確認できます。createApp() は名前空間を持たない関数のため、Tests\Http の中からも修飾なしで呼べます。
② DB を一切踏まない(ShowItemTest.php)
$repository = new InMemoryItemRepository([
偽実装を渡すと、createApp() 内の PdoItemRepository と ConnectionFactory は組み立てられません。PostgreSQL を起動していなくてもテストは通り、CI でも DB コンテナを用意せずに HTTP 層まで確認できる構成です。
8. まとめ
この記事で組み立てた構成は、次のとおりです。
- DI コンテナを持たず、依存を手で並べる
createApp()を 1 つの配線口にする routes.phpはクロージャを返し、new済みのアクション実体を受け取るConnectionFactoryの遅延接続で、GET /healthを DB 非依存に保つ- 同じ
createApp()に偽リポジトリを渡し、DB なしで HTTP 応答を PHPUnit から検証する
起動できないときは 5 章へ、curl の応答が合わないときは 6 章へ、テストが落ちるときは 7 章へ戻ると切り分けやすくなります。
POST を足すなら、addBodyParsingMiddleware() を配線に加え、バリデーションエラーも同じ JSON 形へそろえると後続の API を増やしやすくなります。アクションが増えてきたら、routes.php へ個別に渡す代わりに、ハンドラをまとめて渡す形へ寄せていくとよいでしょう。
DI コンテナを使う構成と読み比べたい場合は、同じシリーズの Slim 4で最小JSON APIを作る(routing + DI + error handling) が対になります。