公開日 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. 4. ルーティングの基本
  8. ここまでの動作確認
  9. 5. ミドルウェアの追加方法
  10. ここまでの動作確認
  11. 6. DI設計の基本方針
  12. ここまでの動作確認
  13. 7. エラーハンドリングとログ
  14. ここまでの動作確認
  15. 8. 業務システム向けの設計ポイント
  16. PostgreSQL を Docker で起動する(8章の前提)
  17. ルートファイルを分割する
  18. Controller肥大化を防ぐ
  19. UserController の完成形
  20. テスト前提で設計する
  21. ここまでの動作確認

Laravel経験者が Slim Framework 4.15 系でバックエンド構成を組み立てるときの導入ガイドです。
テンプレートエンジンやフロントエンド実装は扱わず、HTTP層・DI・ミドルウェア・エラー処理に絞って進めます。

前提環境

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

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

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

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

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

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

この記事では phpdotenv の読み込み実装は扱わず、OS環境変数を前提に進めます。

$_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();

ポイント:

  • 旧Slim独自HTTPクラスは使わず、PSR-7 の ServerRequestInterface / ResponseInterface を使う。
  • getParsedBody() を使う場合は addBodyParsingMiddleware() を追加する。
  • ルート定義やグローバルミドルウェアは index.php で読み込んで接続する。
  • addRoutingMiddleware() を追加してから addErrorMiddleware() を追加する(ErrorMiddlewareを外側に置くことで、ルーティングやアクション内の例外をまとめて処理できる)。
  • 3章の addErrorMiddleware(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:

<?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),
    ]);
};

補足:

  • 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;
        },
    ]);
};

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

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');
    }
}

テスト前提で設計する

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"} を確認。

この記事のサンプルは導入時の最小構成です。
実務では、設定値の注入、テスト戦略、ログ設計をチーム運用に合わせて拡張してください。