Laravel経験者が Slim Framework 4.15 系でバックエンド構成を組み立てるときの導入ガイドです。
テンプレートエンジンやフロントエンド実装は扱わず、HTTP層・DI・ミドルウェア・エラー処理に絞って進めます。
前提環境
この記事は、次のいずれかの環境構築が完了している前提で進めます。
- Windows 11で始めるPHPローカル開発環境(WSL2 + Docker + PostgreSQL)
- Windows 11でPHP 8.3/8.4/8.5を使い分ける環境構築(WSL2 + mise + Docker Compose)
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_order に E が含まれている必要があります。環境によっては $_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/slimslim/psr7php-di/php-di(必要ならmonolog/monolog)が一覧に表示される。
3. 最小構成の index.php
以下は最小起動例です。
Slim 4 の起動に最低限必要な要素(ContainerBuilder、AppFactory::createFromContainer、addRoutingMiddleware、addErrorMiddleware、run)を含みます。
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 OKとpongが返る。 - この時点で定義しているルートは
/pingのみです。/は未定義のため404 Not Foundになります。 - ブラウザ確認:
http://localhost:8080/pingを開いてpongが表示される。
※ /ping は3章の最小起動確認用です。4章以降の確認は /health と /users を基準に進めます。

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\"}"
- 期待結果:
/healthは200、/usersは201。 - ブラウザ確認:
http://localhost:8080/healthで{"status":"ok"}が表示される。
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 する」です。
この章でやること:
config/container.phpにDB接続と interface バインディングを定義する- Controller は Service をコンストラクタで受け取る形にする
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,
];
推奨設定:
| 環境 | displayErrorDetails | logErrors | logErrorDetails |
|---|---|---|---|
| 開発 | true | true | true |
| 本番 | false | true | false |
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 => true、prodでは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.php(HealthController を登録):
<?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/

ここまでの動作確認
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\"}"
- 期待結果:
/healthは200、/usersは201で{"status":"created"}を含む。 - ブラウザ確認:
http://localhost:8080/healthを開いて{"status":"ok"}を確認。
この記事のサンプルは導入時の最小構成です。
実務では、設定値の注入、テスト戦略、ログ設計をチーム運用に合わせて拡張してください。