公開日 2026-03-09

Slim Framework 4 を使った業務Webアプリ導入ガイド(PHP 8.2+)

Laravel経験者向けに、Slim Framework 4で業務Webアプリのバックエンド構成を組み立てる入口を整理する。

目次

  1. 前提環境
  2. 1. Slimとは何か(簡潔に)
  3. 2. インストール手順
  4. ここまでの動作確認
  5. 3. 最小構成の index.php
  6. コードのポイント
  7. ここまでの動作確認
  8. ここまでの動作確認
  9. 5. ミドルウェアの追加方法
  10. ここまでの動作確認
  11. 6. DI設計の基本方針
  12. コードのポイント
  13. ここまでの動作確認
  14. 7. エラーハンドリングとログ
  15. コードのポイント
  16. ここまでの動作確認
  17. 8. 業務システム向けの設計ポイント
  18. PostgreSQL を Docker で起動する(8章の前提)
  19. ルートファイルを分割する
  20. Controller肥大化を防ぐ
  21. UserController の完成形
  22. コードのポイント
  23. テスト前提で設計する
  24. まとめ
  25. 参考リンク

Laravel経験者が Slim Framework 4.15 系でバックエンド構成を組み立てるときの導入ガイドです。PHP-DI でDIコンテナを構成し、PSR-7ミドルウェア・エラーハンドリング・PostgreSQL連携を備えたREST APIを最小構成で構築します。テンプレートエンジンやフロントエンド実装は扱わず、HTTP層・DI・ミドルウェア・エラー処理に絞ります。

前提環境

この記事は、次のいずれかの環境構築が完了していることを前提とします。

1. Slimとは何か(簡潔に)

Slim は、HTTPリクエスト/レスポンス処理とルーティングに集中した PHP のマイクロフレームワークです。
Laravel が認証・ORM・テンプレートなどを含むフルスタック構成なのに対し、Slim は必要な部品を自分で選んで組み立てます。

向いているケースは次のとおりです。

  • API中心のシステムを軽量に構築したい
  • 既存基盤にHTTPエンドポイントだけ追加したい
  • 構成要素を明示的に管理したい(DI、ミドルウェア、ログ設計を自分で決めたい)

向いていないケースも把握しておきます。

  • 認証・ORM・テンプレートをすぐ使いたい場合(Laravelの方が早い)
  • チームがフレームワーク規約に頼りたい場合(設計判断コストが高い)

Slim は軽量ですが、同梱機能が少ないぶん開発側が設計責任を負います。 HTTP層を自分で組み立てたい場面で導入候補になりやすいフレームワークです。

2. インストール手順

作業用のプロジェクトフォルダを作成します。

mkdir -p ~/projects/slim4-project
cd ~/projects/slim4-project

必須3パッケージを導入します。

composer require slim/slim:^4.15 slim/psr7 php-di/php-di

エラーログをPSR-3ロガーで扱う場合は、追加で Monolog を導入します(7章で使用)。

composer require monolog/monolog

8章のテスト例を実行する場合は、PHPUnit も導入します。

composer require --dev phpunit/phpunit

Laravel と違い、Slim は .env を自動ロードしません。
本記事の $_ENV は「OS環境変数が設定済み」である前提です。.env ファイルを使う場合は、必要に応じて phpdotenv を導入してください。

composer require vlucas/phpdotenv

$_ENV を使うには、PHP の variables_orderE が含まれている必要があります。環境によっては $_ENV が空になるため、その場合は getenv() を使うか、php.ini の設定を確認してください。

次に、src/ 配下のクラスを App\ 名前空間として読み込めるよう、PSR-4 オートロードを設定します。

composer.json(追記例):

{
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer dump-autoload

ディレクトリ構成例:

slim4-project/
├─ app/
│  ├─ middleware.php
│  ├─ routes.php
│  └─ routes/
├─ config/
│  ├─ container.php
│  └─ settings.php
├─ public/
│  └─ index.php
├─ src/
│  ├─ Controller/
│  ├─ Middleware/
│  ├─ Service/
│  └─ Repository/
├─ tests/
│  └─ Service/
└─ var/
   └─ log/

ここまでの動作確認

composer show --direct
  • 期待結果: slim/slim slim/psr7 php-di/php-di(必要なら monolog/monolog)が一覧に表示される。 slim4 intro guide composer show direct

3. 最小構成の index.php

次は、Slim 4 の起動に最低限必要な要素(ContainerBuilderAppFactory::createFromContaineraddRoutingMiddlewareaddErrorMiddlewarerun)を含む最小起動例です。

config/container.php(最小定義):

<?php
declare(strict_types=1);

use DI\ContainerBuilder;

return static function (ContainerBuilder $builder): void {
    // 3章時点では空でOK。6章で定義を追加します。
};

app/middleware.php(最小定義):

<?php
declare(strict_types=1);

use Slim\App;

return static function (App $app): void {
};

app/routes.php(最小定義):

<?php
declare(strict_types=1);

use Slim\App;

return static function (App $app): void {
};

※ 4章と5章で、それぞれ実装内容に置き換えます。

public/index.php:

<?php
declare(strict_types=1);

use DI\ContainerBuilder;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Factory\AppFactory;

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

$builder = new ContainerBuilder();
(require __DIR__ . '/../config/container.php')($builder);
$container = $builder->build();

$app = AppFactory::createFromContainer($container);
// 本記事では、コンテナ起動に統一するため createFromContainer() を採用します。

$app->get(
    '/ping',
    function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
        $response->getBody()->write('pong');
        return $response;
    }
);

$app->addBodyParsingMiddleware();

(require __DIR__ . '/../app/middleware.php')($app);
(require __DIR__ . '/../app/routes.php')($app);

$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);

$app->run();

コードのポイント

AppFactory::createFromContainer でコンテナ起動に統一する

$app = AppFactory::createFromContainer($container);

AppFactory::create() を使うとコンテナが接続されず、6章以降の依存注入が機能しません。コンテナ前提の構成なので、起動方式を最初から createFromContainer() に固定しています。

addBodyParsingMiddleware を省略すると getParsedBody()null を返す

$app->addBodyParsingMiddleware();

JSON や form-urlencoded のリクエストボディを getParsedBody() で配列として取り出すために必要です。この行を省くと 4章の UserController でボディ取得が機能しません。

③ Routing → Error の登録順序

$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);

addErrorMiddleware を後に呼ぶとスタックの外側(最初に実行される位置)に置かれ、ルーティングエラーや Controller 内の例外をまとめて捕捉できます。順序を逆にするとルーティング例外が ErrorMiddleware を素通りします。3章の (true, true, true) は最小構成で、実運用では7章の環境別設定に置き換えます。

ここまでの動作確認

php -S はローカル開発確認用です。本番環境では Web サーバー + PHP-FPM などの構成を使用してください。

php -S localhost:8080 -t public public/index.php

別ターミナルで確認:

curl -i http://localhost:8080/ping
  • 期待結果: 200 OKpong が返る。
  • この時点で定義しているルートは /ping のみです。/ は未定義のため 404 Not Found になります。
  • ブラウザ確認: http://localhost:8080/ping を開いて pong が表示される。

/ping は3章の最小起動確認用です。4章以降の確認は /health/users を基準に進めます。

slim4 intro guide localhost8080 ping ## 4. ルーティングの基本

ルーティングは、宣言だけを薄く書いて Controller に処理を寄せます。

app/routes.php:

<?php
declare(strict_types=1);

use App\Controller\HealthController;
use App\Controller\UserController;
use Slim\App;

return static function (App $app): void {
    $app->get('/health', HealthController::class . ':index');
    $app->post('/users', UserController::class . ':store');
};

src/Controller/HealthController.php:

<?php
declare(strict_types=1);

namespace App\Controller;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class HealthController
{
    public function index(
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $args
    ): ResponseInterface {
        $response->getBody()->write(
            (string) json_encode(['status' => 'ok'], JSON_THROW_ON_ERROR)
        );

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

src/Controller/UserController.php:

<?php
declare(strict_types=1);

namespace App\Controller;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class UserController
{
    public function store(
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $args
    ): ResponseInterface {
        $body = (array) ($request->getParsedBody() ?? []);
        $name = $body['name'] ?? '';
        $email = $body['email'] ?? '';

        if (!is_string($name) || $name === '' || !is_string($email) || $email === '') {
            $response->getBody()->write(
                (string) json_encode(['error' => 'name and email are required'], JSON_THROW_ON_ERROR)
            );
            return $response->withStatus(422)->withHeader('Content-Type', 'application/json');
        }

        $response->getBody()->write(
            (string) json_encode(
                ['id' => 1, 'name' => $name, 'email' => $email],
                JSON_THROW_ON_ERROR
            )
        );

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

ここまでの動作確認

curl -i http://localhost:8080/health
curl -i -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d "{\"name\":\"Taro\",\"email\":\"taro@example.com\"}"
  • 期待結果: /health200/users201
  • ブラウザ確認: http://localhost:8080/health{"status":"ok"} が表示される。 slim4 intro guide localhost8080 health

5. ミドルウェアの追加方法

Slim 4 では、クラスミドルウェアをグローバルにもルート単位にも追加できます。

src/Middleware/RequestIdMiddleware.php(クラスミドルウェア例):

<?php
declare(strict_types=1);

namespace App\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class RequestIdMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ): ResponseInterface {
        $requestId = bin2hex(random_bytes(8));
        $request = $request->withAttribute('request_id', $requestId);

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

        return $response->withHeader('X-Request-Id', $requestId);
    }
}

src/Middleware/ApiKeyMiddleware.php(ルート保護用):

<?php
declare(strict_types=1);

namespace App\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;

final class ApiKeyMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ): ResponseInterface {
        $expected = $_ENV['API_KEY'] ?? 'local-dev-key';
        $provided = $request->getHeaderLine('X-API-Key');

        if ($provided !== $expected) {
            // サンプルのため slim/psr7 の具体クラスを直接使用しています。
            // 実務では ResponseFactoryInterface を注入して生成する方法を推奨します。
            $response = new Response(401);
            $response->getBody()->write(
                (string) json_encode(['error' => 'unauthorized'], JSON_THROW_ON_ERROR)
            );
            return $response->withHeader('Content-Type', 'application/json');
        }

        return $handler->handle($request);
    }
}

グローバル追加(app/middleware.php):

<?php
declare(strict_types=1);

use App\Middleware\RequestIdMiddleware;
use Slim\App;

return static function (App $app): void {
    $app->add(RequestIdMiddleware::class);
};

ルート単位追加(app/routes.php):

4章で作成した app/routes.php を、次の内容に更新します。

<?php
declare(strict_types=1);

use App\Controller\HealthController;
use App\Controller\UserController;
use App\Middleware\ApiKeyMiddleware;
use Slim\App;

return static function (App $app): void {
    $app->get('/health', HealthController::class . ':index');
    $app->post('/users', UserController::class . ':store')
        ->add(ApiKeyMiddleware::class);
};

注記:

  • ApiKeyMiddleware$_ENV['API_KEY'] ?? 'local-dev-key' で判定します。
  • API_KEY を明示設定したい場合は、WSL/bash で次を実行します。
export API_KEY=local-dev-key
  • 新しいターミナルを開いた場合は、必要に応じて再設定してください。
  • 実務では設定値をコンテナ定義から注入し、ミドルウェア本体から環境変数参照を切り離す設計を推奨します。

実行順(LIFO):

$app->add(MiddlewareA::class);
$app->add(MiddlewareB::class);
  • リクエスト時: MiddlewareB -> MiddlewareA -> Route
  • レスポンス時: Route -> MiddlewareA -> MiddlewareB

ここまでの動作確認

curl -i -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d "{\"name\":\"Taro\",\"email\":\"taro@example.com\"}"

curl -i -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -H "X-API-Key: local-dev-key" \
  -d "{\"name\":\"Taro\",\"email\":\"taro@example.com\"}"
  • 期待結果: APIキーなしは 401、ありは 201
  • X-Request-Id ヘッダーが返ってくることも確認する。

6. DI設計の基本方針

Slim では、DI設計を最初に決めることがテスト容易性と変更コストに直接影響します。 基本は「Controllerに必要な依存だけを constructor injection する」です。

この章でやること:

  1. config/container.php にDB接続と interface バインディングを定義する
  2. Controller は Service をコンストラクタで受け取る形にする
  3. container->get() の直接呼び出し(Service Locator)を避ける

config/container.php に PDO 接続ファクトリとインターフェースバインディングを定義します。

<?php
declare(strict_types=1);

use App\Repository\PdoUserRepository;
use App\Repository\UserRepositoryInterface;
use DI\ContainerBuilder;
use function DI\autowire;

return static function (ContainerBuilder $builder): void {
    $builder->addDefinitions([
        PDO::class => static function (): PDO {
            $dsn = $_ENV['DB_DSN'] ?? '';
            if ($dsn === '') {
                throw new RuntimeException('DB_DSN is required. Set OS env vars or load .env via phpdotenv.');
            }
            $pdo = new PDO(
                $dsn,
                $_ENV['DB_USER'] ?? null,
                $_ENV['DB_PASS'] ?? null
            );
            $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            return $pdo;
        },
        UserRepositoryInterface::class => autowire(PdoUserRepository::class),
    ]);
};

コードのポイント

① クロージャで PDO を遅延生成する

PDO::class => static function (): PDO {

PDO::class をキーに無名関数を登録すると、コンテナが PDO を要求した時点で初めて接続を生成します。起動時ではなく必要になったタイミングで実行されるため、DB が不要なルートでは接続を張りません。

ERRMODE_EXCEPTION でエラーを例外に変換する

$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

PDO のデフォルト(ERRMODE_SILENT)では SQL エラーが無視されます。ERRMODE_EXCEPTION に設定すると PDOException が投げられ、Slim の ErrorMiddleware で一元的に処理できます。

autowire() でインターフェースと実装を結びつける

UserRepositoryInterface::class => autowire(PdoUserRepository::class),

PHP-DI の autowire()PdoUserRepository のコンストラクタを読んで依存を自動解決します。Controller は UserRepositoryInterface だけを型宣言し、実装クラスを直接参照せずに済みます。

補足:

  • 3章の (require __DIR__ . '/../config/container.php')($builder); で、この定義をアプリ起動時に読み込みます。
  • 6章/7章の定義は1つの config/container.php に統合します(別ファイルに分けません)。

参考: 推奨パターン(比較用):

final class UserController
{
    public function __construct(private \App\Service\UserService $userService)
    {
    }
}

参考: 非推奨パターン(比較用):

use Psr\Container\ContainerInterface;

final class UserController
{
    public function __construct(private ContainerInterface $container)
    {
    }

    public function store(): void
    {
        $service = $this->container->get(\App\Service\UserService::class);
    }
}

Service Locator は依存関係をコードから隠し、テスト時の差し替えを難しくします。

ここまでの動作確認

php -l config/container.php
php -l src/Controller/UserController.php
  • 期待結果: いずれも No syntax errors detected
  • 依存解決の最終確認は8章の /users 動作確認で行う。

7. エラーハンドリングとログ

addErrorMiddleware で、開発/本番の挙動を明示的に分けます。

config/settings.php:

<?php
declare(strict_types=1);

$env = $_ENV['APP_ENV'] ?? 'prod';
$isDev = $env === 'dev';

return [
    'displayErrorDetails' => $isDev,
    'logErrors' => true,
    'logErrorDetails' => $isDev,
];

推奨設定:

環境displayErrorDetailslogErrorslogErrorDetails
開発truetruetrue
本番falsetruefalse

PSR-3ロガー(Monolog)を注入する場合は、config/container.php を次の内容に更新します。

config/container.php(7章時点のフル版):

<?php
declare(strict_types=1);

use App\Repository\PdoUserRepository;
use App\Repository\UserRepositoryInterface;
use DI\ContainerBuilder;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
use function DI\autowire;

return static function (ContainerBuilder $builder): void {
    $builder->addDefinitions([
        PDO::class => static function (): PDO {
            $dsn = $_ENV['DB_DSN'] ?? '';
            if ($dsn === '') {
                throw new RuntimeException('DB_DSN is required. Set OS env vars or load .env via phpdotenv.');
            }
            $pdo = new PDO(
                $dsn,
                $_ENV['DB_USER'] ?? null,
                $_ENV['DB_PASS'] ?? null
            );
            $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            return $pdo;
        },
        UserRepositoryInterface::class => autowire(PdoUserRepository::class),
        LoggerInterface::class => static function (): LoggerInterface {
            $logger = new Logger('app');
            $logger->pushHandler(new StreamHandler(__DIR__ . '/../var/log/app.log'));
            return $logger;
        },
    ]);
};

コードのポイント

LoggerInterface を PSR-3 インターフェースで登録する

LoggerInterface::class => static function (): LoggerInterface {
    $logger = new Logger('app');
    $logger->pushHandler(new StreamHandler(__DIR__ . '/../var/log/app.log'));

Psr\Log\LoggerInterface をキーにすることで、Controller や Service は Monolog に直接依存せず PSR-3 インターフェースだけを型宣言できます。ログ出力先やハンドラーを変えるときはファクトリ内だけ修正すれば済みます。

ログ書き込み先ディレクトリを作成しておきます。

mkdir -p var/log

public/index.php では、3章の addErrorMiddleware(true, true, true) を以下に置き換えます。

$settings = require __DIR__ . '/../config/settings.php';

$app->addErrorMiddleware(
    $settings['displayErrorDetails'],
    $settings['logErrors'],
    $settings['logErrorDetails'],
    $container->get(\Psr\Log\LoggerInterface::class)
);

ここまでの動作確認

APP_ENV=dev php -r '$s=require "config/settings.php"; var_export($s);'
APP_ENV=prod php -r '$s=require "config/settings.php"; var_export($s);'
  • 期待結果: dev では displayErrorDetails => trueprod では false
  • var/log ディレクトリが存在することを確認する(app.log はエラー発生時に生成される)。

8. 業務システム向けの設計ポイント

PostgreSQL を Docker で起動する(8章の前提)

8章の /users をDB保存に切り替える前に、PostgreSQL を起動します。
前提記事で既にDBを起動済みなら、この節はスキップしてください。

compose.yml(最小例):

services:
  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 compose up -d db
docker compose ps

アプリ側の環境変数を設定します。

export APP_ENV='dev'
export API_KEY='local-dev-key'
export DB_DSN='pgsql:host=127.0.0.1;port=5432;dbname=app'
export DB_USER='app'
export DB_PASS='app'

psql をホストに入れていない場合は、コンテナ内でテーブルを作成できます。

docker compose exec -T db psql -U app -d app \
  -c "CREATE TABLE IF NOT EXISTS users (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL);"

ルートファイルを分割する

mkdir -p app/routes

app/routes.php:

<?php
declare(strict_types=1);

use Slim\App;

return static function (App $app): void {
    (require __DIR__ . '/routes/health.php')($app);
    (require __DIR__ . '/routes/users.php')($app);
};

app/routes/users.php:

<?php
declare(strict_types=1);

use App\Controller\UserController;
use App\Middleware\ApiKeyMiddleware;
use Slim\App;

return static function (App $app): void {
    $app->post('/users', UserController::class . ':store')->add(ApiKeyMiddleware::class);
};

app/routes/health.phpHealthController を登録):

<?php
declare(strict_types=1);

use App\Controller\HealthController;
use Slim\App;

return static function (App $app): void {
    $app->get('/health', HealthController::class . ':index');
};

Controller肥大化を防ぐ

  • Controller: HTTP入出力(入力の受け取り、レスポンス整形)
  • Service: 業務ルール
  • Repository: 永続化(SQL/DBアクセス)

src/Repository/UserRepositoryInterface.php:

<?php
declare(strict_types=1);

namespace App\Repository;

interface UserRepositoryInterface
{
    public function insert(string $name, string $email): int;
}

src/Repository/PdoUserRepository.php:

<?php
declare(strict_types=1);

namespace App\Repository;

use PDO;

final class PdoUserRepository implements UserRepositoryInterface
{
    public function __construct(private PDO $pdo)
    {
    }

    public function insert(string $name, string $email): int
    {
        // PostgreSQL 固有構文です。MySQL を使う場合は lastInsertId() 方式に置き換えてください。
        $stmt = $this->pdo->prepare('INSERT INTO users (name, email) VALUES (:name, :email) RETURNING id');
        $stmt->execute(['name' => $name, 'email' => $email]);

        return (int) $stmt->fetchColumn();
    }
}

src/Service/UserService.php:

<?php
declare(strict_types=1);

namespace App\Service;

use App\Repository\UserRepositoryInterface;

final class UserService
{
    public function __construct(private UserRepositoryInterface $userRepository)
    {
    }

    public function createUser(string $name, string $email): int
    {
        return $this->userRepository->insert($name, $email);
    }
}

UserController の完成形

4章の UserController を、6章/8章の設計に合わせて次の形へ更新します。

<?php
declare(strict_types=1);

namespace App\Controller;

use App\Service\UserService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class UserController
{
    public function __construct(private UserService $userService)
    {
    }

    public function store(
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $args
    ): ResponseInterface {
        $body = (array) ($request->getParsedBody() ?? []);
        $name = $body['name'] ?? '';
        $email = $body['email'] ?? '';

        if (!is_string($name) || $name === '' || !is_string($email) || $email === '') {
            $response->getBody()->write(
                (string) json_encode(['error' => 'name and email are required'], JSON_THROW_ON_ERROR)
            );
            return $response->withStatus(422)->withHeader('Content-Type', 'application/json');
        }

        $insertId = $this->userService->createUser($name, $email);

        $response->getBody()->write(
            (string) json_encode(['id' => $insertId, 'status' => 'created'], JSON_THROW_ON_ERROR)
        );

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

コードのポイント

① コンストラクタで UserService を受け取る

    public function __construct(private UserService $userService)

PHP-DI の autowire がコンストラクタを読み取り、UserService を自動で注入します。Controller が持つ依存を型宣言として明示することで、テスト時にモックへ差し替えられる設計になります。

② 業務処理を UserService に委ねる

        $insertId = $this->userService->createUser($name, $email);

Controller は HTTP 入出力(バリデーション・レスポンス整形)に集中し、ユーザー作成ロジックは UserService が担います。createUser の戻り値(採番 ID)だけを受け取ることで、DB の詳細が Controller に漏れません。

テスト前提で設計する

Service層は interface 経由にしておくと、DBなしで単体テストできます。

tests/Service/UserServiceTest.php:

<?php
declare(strict_types=1);

use App\Repository\UserRepositoryInterface;
use App\Service\UserService;
use PHPUnit\Framework\TestCase;

final class UserServiceTest extends TestCase
{
    public function testCreateUserDelegatesToRepository(): void
    {
        $repo = $this->createMock(UserRepositoryInterface::class);
        $repo->expects($this->once())
            ->method('insert')
            ->with('Taro', 'taro@example.com')
            ->willReturn(10);

        $service = new UserService($repo);

        $this->assertSame(10, $service->createUser('Taro', 'taro@example.com'));
    }
}
./vendor/bin/phpunit tests/
slim4 intro guide phpunit ### ここまでの動作確認 `APP_ENV` / `DB_*` / `API_KEY` は、`php -S` を起動した同じシェルで有効です。

php -S はローカル開発確認用です。本番環境では Web サーバー + PHP-FPM などの構成を使用してください。

php -S localhost:8080 -t public public/index.php
curl -i http://localhost:8080/health
curl -i -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -H "X-API-Key: local-dev-key" \
  -d "{\"name\":\"Taro\",\"email\":\"taro@example.com\"}"
  • 期待結果: /health200/users201{"status":"created"} を含む。
  • ブラウザ確認: http://localhost:8080/health を開いて {"status":"ok"} を確認。

まとめ

この記事では、Slim 4 + PHP-DI を使って次の構成を一通り組み立てました。

  • ルーティングとコントローラー分離(4章)
  • PSR-15ミドルウェアによるリクエスト/レスポンス処理(5章)
  • DIコンテナによる依存管理(6章)
  • 環境別エラーハンドリングとMonologによるログ出力(7章)
  • Repository/Service層の分離とPHPUnit単体テスト(8章)

実務への次のステップとしては、設定値の注入方法の整理、統合テスト戦略、本番デプロイ構成(Nginx + PHP-FPM)の検討が挙げられます。

参考リンク

シリーズ 1/3

このシリーズ

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最小構成)