公開日 2026-04-12

Slim 4 + PostgreSQLで認証付きREST APIを作る(JWT最小構成)

Slim 4 と PostgreSQL を使い、JWT 認証付き REST API の最小構成を register / login / me の 3 本で再現できる。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 新規デモ環境を作成して起動する
  4. 3. PostgreSQLを初期化して users テーブルを作る
  5. 4. Slimを導入して最小起動コードを作る
  6. コードのポイント
  7. 5. register と login を実装する
  8. コードのポイント
  9. 6. JWT middleware で GET /api/me を保護する
  10. コードのポイント
  11. 7. curl で成功系と失敗系を確認する
  12. ユーザー登録
  13. ログイン
  14. 認証済みユーザー情報の取得
  15. 失敗系の確認
  16. DB側でも確認する
  17. 8. まとめと次の一歩

対象読者: WSL2 + Docker で PHP 開発中で、Laravel などでは認証付き API を触ったことがあるが、Slim 4 で同等の最小構成を自力で組みたい方

本記事では、Slim 4 + PostgreSQL + JWT で「ユーザー登録・ログイン・保護API」を最小構成で通します。 完成時のエンドポイントは POST /api/registerPOST /api/loginGET /api/me の 3 本です。起動確認用に GET /health も置きます。

確認は curl を主線にします。 refresh token、logout、roles、ORM、自動テストは扱いません。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL連携有効)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。

1. ゴールと非対象

この記事で到達する状態:

  • POST /api/register でユーザーを PostgreSQL に登録できる
  • POST /api/login で JWT を発行できる
  • GET /api/meAuthorization: Bearer <token> 付きで認証済みユーザー情報を返せる
  • 重複登録、認証失敗、未認証、不正トークン、404 で JSON エラーを返せる

この記事で扱わない内容:

  • refresh token / logout / token blacklist
  • ロール・権限管理(admin / user など)
  • メール認証 / パスワードリセット
  • ORM / migration ツール
  • OpenAPI / Swagger
  • PHPUnit や結合テスト
  • フロントエンドでの token 保存戦略

JWT は access token 1 本だけを扱います。

2. 新規デモ環境を作成して起動する

WSL 側のシェルで作業します。 Windows 側から始める場合は wsl で Ubuntu に入り、以下を実行してください。

# Windows側から始める場合のみ実行
# wsl
mkdir -p ~/projects/slim4-jwt-auth-demo
cd ~/projects/slim4-jwt-auth-demo
mkdir -p docker/php docker/db config app public src/Action src/Handler src/Middleware src/Repository src/Service
code .

compose.yml を作成します。 app には env_file で DB 接続値と JWT secret を渡し、コードへの直書きを避けます。 db には healthcheck を入れて、初回接続エラーを減らします。

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /workspace
    volumes:
      - ./:/workspace
    ports:
      - "8080:8080"
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
    command: ["sleep", "infinity"]

  db:
    image: postgres:17
    env_file:
      - .env
    volumes:
      - ./docker/db/init.sql:/docker-entrypoint-initdb.d/init.sql
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  db-data:

docker/php/Dockerfile を作成します。

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

.env.example を作成します。

POSTGRES_USER=app_user
POSTGRES_PASSWORD=app_pass
POSTGRES_DB=app_db

DB_HOST=db
DB_PORT=5432

JWT_SECRET=8f9c4a2b7d6e1f0a8b3c5d7e9f1a2b4c6d8e0f1a3b5c7d9e2f4a6b8c0d1e3f5

firebase/php-jwtHS256 を使う場合、secret が短すぎると Provided key is too short で失敗します。 記事どおりに手を動かす段階でも、JWT_SECRET は十分長いランダム文字列にしてください。

自分で生成する場合は、例えば次のように作れます。

openssl rand -hex 32

このコマンドは 64 文字の16進文字列を返します。 本番ではサンプル値をそのまま使わず、生成した値へ置き換えてください。

composer.json を作成します。 autoload.psr-4App\\ => src/ を登録しておくと、後続のクラスを素直に読み込めます。

{
  "name": "example/slim4-jwt-auth-demo",
  "type": "project",
  "require": {},
  "require-dev": {},
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}

.env.example をコピーして起動します。

cp .env.example .env
docker compose up -d --build

起動を確認します。

docker compose ps
docker compose exec app php -v
docker compose exec app composer --version
docker compose exec app php -m | grep pdo_pgsql

pdo_pgsql が表示されれば、PHP から PostgreSQL に接続する準備ができています。

docker compose ps と pdo_pgsql の確認画面

詰まり時:

  • コンテナが起動しない: docker compose logs app / docker compose logs db
  • Dockerfile 変更後に反映されない: docker compose up -d --build で再ビルド

3. PostgreSQLを初期化して users テーブルを作る

docker/db/init.sql を作成します。 password を平文で保存せず、password_hash カラムに PHP 側で生成したハッシュを入れる前提です。

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

初期化 SQL は docker-entrypoint-initdb.d 経由でコンテナ初回起動時に実行されます。 既にボリュームが存在する場合は SQL が再実行されないため、テーブル定義を変えたときはボリュームの再作成が必要です。

# ボリュームを再作成する場合
docker compose down -v
docker compose up -d --build

テーブルの存在とカラム定義を確認します。

docker compose exec db psql -U app_user -d app_db -c "\dt"
docker compose exec db psql -U app_user -d app_db -c "\d users"
psql で users テーブル一覧を確認した画面 psql で users テーブル定義を確認した画面

詰まり時:

  • テーブルが見えない: docker compose down -v してから docker compose up -d --build
  • 認証失敗: .envPOSTGRES_USER / POSTGRES_PASSWORDcompose.ymlenv_file を確認

4. Slimを導入して最小起動コードを作る

Slim と関連パッケージを導入します。

docker compose exec app composer require slim/slim:^4.15 slim/psr7:^1.8 php-di/php-di:^7.1 firebase/php-jwt:^7.0

config/container.php を作成します。 この段階では PDO の定義と JWT secret の読み取り基盤だけを置きます。

<?php
declare(strict_types=1);

use App\Handler\JsonErrorHandler;
use DI\ContainerBuilder;

return static function (ContainerBuilder $containerBuilder): void {
    $containerBuilder->addDefinitions([
        PDO::class => function (): PDO {
            $host = $_ENV['DB_HOST'] ?? 'db';
            $port = $_ENV['DB_PORT'] ?? '5432';
            $dbname = $_ENV['POSTGRES_DB'] ?? 'app_db';
            $user = $_ENV['POSTGRES_USER'] ?? 'app_user';
            $pass = $_ENV['POSTGRES_PASSWORD'] ?? 'app_pass';

            $dsn = "pgsql:host={$host};port={$port};dbname={$dbname}";

            return new PDO($dsn, $user, $pass, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
        },

        'jwt.secret' => function (): string {
            return $_ENV['JWT_SECRET'] ?? 'change-me-to-random-string';
        },

        JsonErrorHandler::class => DI\autowire(),
    ]);
};

src/Handler/JsonErrorHandler.php を作成します。 認証失敗や 404 を含め、すべてのエラーを JSON 形式に統一します。

<?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\HttpException;
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 HttpException) {
            $status = $exception->getCode();
            $message = $exception->getMessage();
        }

        if ($status === 404) {
            $message = 'Not Found';
        } elseif ($status === 405) {
            $message = 'Method Not Allowed';
        } elseif ($status === 500 && $displayErrorDetails && $exception->getMessage() !== '') {
            $message = $exception->getMessage();
        }

        $payload = [
            'error' => [
                'message' => $message,
                'status' => $status,
            ],
        ];

        $response = $this->responseFactory->createResponse($status);
        $response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

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

public/index.php を作成します。

<?php
declare(strict_types=1);

use App\Handler\JsonErrorHandler;
use DI\ContainerBuilder;
use Slim\Factory\AppFactory;

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

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

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

$app->addBodyParsingMiddleware();
$app->addRoutingMiddleware();
$errorMiddleware = $app->addErrorMiddleware(true, true, true);
$errorMiddleware->setDefaultErrorHandler($container->get(JsonErrorHandler::class));

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

$app->run();

app/routes.php を作成します。 最初は GET /health だけを定義します。

<?php
declare(strict_types=1);

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

return static function (App $app): void {
    $app->get('/health', function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
        $response->getBody()->write(json_encode([
            'status' => 'ok',
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

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

ContainerBuilder + AppFactory::createFromContainer() を使うことで、後続の Action や Repository を同じ container で解決できます。 addRoutingMiddleware()addErrorMiddleware() より先に追加すると、404 / 405 などのルート関連エラーを error middleware 側で扱えるようになります。

別ターミナルを開き、HTTP サーバーを手動起動します。 このコマンドは起動したままになるので、止めるまではそのターミナルを閉じずに置いてください。

docker compose exec app php -S 0.0.0.0:8080 -t public public/index.php

確認します。

curl -i http://localhost:8080/health

実行結果例:

HTTP/1.1 200 OK
Content-Type: application/json

{"status":"ok"}
GET /health が 200 と JSON を返している画面

詰まり時:

  • Class not found: docker compose exec app composer dump-autoload
  • DB 接続エラー: .env の値と config/container.php の DSN を見直す

コードのポイント

① PDO 接続オプション(config/container.php

            return new PDO($dsn, $user, $pass, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);

PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION は PDO エラーを例外として投げる設定で、error middleware で補足できるようになる。PDO::FETCH_ASSOC は行を連想配列で返すよう統一し、数値インデックスの誤参照を防ぐ。

② 文字列キーによる JWT secret の登録(config/container.php

        'jwt.secret' => function (): string {
            return $_ENV['JWT_SECRET'] ?? 'change-me-to-random-string';
        },

クラス名ではなく 'jwt.secret' という文字列キーで登録することで、JwtService のコンストラクタに文字列依存を明示的に渡せる。$_ENV から読むことでコードへの直書きを避けている。

HttpException 基底クラスで一括補足(JsonErrorHandler.php

        if ($exception instanceof HttpException) {
            $status = $exception->getCode();
            $message = $exception->getMessage();
        }

        if ($status === 404) {
            $message = 'Not Found';
        } elseif ($status === 405) {
            $message = 'Method Not Allowed';
        } elseif ($status === 500 && $displayErrorDetails && $exception->getMessage() !== '') {
            $message = $exception->getMessage();
        }

HttpException 基底クラスで一括補足してステータスとメッセージを取り出す。404 / 405 のメッセージは固定値に上書きし、500 は displayErrorDetailstrue のときのみ詳細を出力する。

5. registerlogin を実装する

ユーザー登録とログインを実装し、JWT を発行できる状態にします。

src/Repository/UserRepository.php を作成します。 DB 操作はここに寄せ、Action では HTTP 入出力に集中させます。

<?php
declare(strict_types=1);

namespace App\Repository;

use PDO;

final class UserRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    public function findByEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE email = :email');
        $stmt->execute(['email' => $email]);
        $row = $stmt->fetch();

        return $row !== false ? $row : null;
    }

    public function findById(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch();

        return $row !== false ? $row : null;
    }

    public function create(string $name, string $email, string $passwordHash): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (name, email, password_hash) VALUES (:name, :email, :password_hash) RETURNING id'
        );
        $stmt->execute([
            'name' => $name,
            'email' => $email,
            'password_hash' => $passwordHash,
        ]);

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

PostgreSQL では RETURNING id でその場で主キーを受けると、シーケンス名や PDO ドライバ差分に引きずられにくくなります。

src/Service/JwtService.php を作成します。 token の発行と検証をここにまとめます。後続の middleware でも同じサービスを使います。

<?php
declare(strict_types=1);

namespace App\Service;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

final class JwtService
{
    private string $secret;
    private string $algorithm = 'HS256';
    private int $ttl = 3600;

    public function __construct(string $secret)
    {
        $this->secret = $secret;
    }

    public function encode(int $userId): string
    {
        $now = time();
        $payload = [
            'sub' => $userId,
            'iat' => $now,
            'exp' => $now + $this->ttl,
        ];

        return JWT::encode($payload, $this->secret, $this->algorithm);
    }

    public function decode(string $token): object
    {
        return JWT::decode($token, new Key($this->secret, $this->algorithm));
    }
}

secret は .env 経由で受け取り、コードに直書きしません。 exp を設定することで、token に有効期限を持たせています。ここでは 1 時間にしています。 HS256 の secret は短すぎると login 時の token 発行で失敗するため、2章の .env では十分長いランダム文字列を使ってください。

src/Action/RegisterUserAction.php を作成します。 password_hash() でパスワードをハッシュ化してから保存します。平文のまま DB に入れてはいけません。

<?php
declare(strict_types=1);

namespace App\Action;

use App\Repository\UserRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class RegisterUserAction
{
    public function __construct(private UserRepository $userRepository)
    {
    }

    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $body = $request->getParsedBody();

        if (!is_array($body)) {
            return $this->json($response, ['error' => ['message' => 'Request body must be a JSON object.', 'status' => 400]], 400);
        }

        $name = trim((string) ($body['name'] ?? ''));
        $email = trim((string) ($body['email'] ?? ''));
        $password = (string) ($body['password'] ?? '');

        if ($name === '' || $email === '' || $password === '') {
            return $this->json($response, ['error' => ['message' => 'name, email, and password are required.', 'status' => 400]], 400);
        }

        if ($this->userRepository->findByEmail($email) !== null) {
            return $this->json($response, ['error' => ['message' => 'Email already registered.', 'status' => 409]], 409);
        }

        $passwordHash = password_hash($password, PASSWORD_DEFAULT);
        $userId = $this->userRepository->create($name, $email, $passwordHash);

        return $this->json($response, [
            'user' => [
                'id' => $userId,
                'name' => $name,
                'email' => $email,
            ],
        ], 201);
    }

    private function json(ResponseInterface $response, array $data, int $status): ResponseInterface
    {
        $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

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

src/Action/LoginAction.php を作成します。 password_verify() でハッシュと照合し、一致すれば JWT を返します。

<?php
declare(strict_types=1);

namespace App\Action;

use App\Repository\UserRepository;
use App\Service\JwtService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class LoginAction
{
    public function __construct(
        private UserRepository $userRepository,
        private JwtService $jwtService,
    ) {
    }

    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $body = $request->getParsedBody();

        if (!is_array($body)) {
            return $this->json($response, ['error' => ['message' => 'Request body must be a JSON object.', 'status' => 400]], 400);
        }

        $email = trim((string) ($body['email'] ?? ''));
        $password = (string) ($body['password'] ?? '');

        if ($email === '' || $password === '') {
            return $this->json($response, ['error' => ['message' => 'email and password are required.', 'status' => 400]], 400);
        }

        $user = $this->userRepository->findByEmail($email);

        if ($user === null || !password_verify($password, $user['password_hash'])) {
            return $this->json($response, ['error' => ['message' => 'Invalid email or password.', 'status' => 401]], 401);
        }

        $token = $this->jwtService->encode((int) $user['id']);

        return $this->json($response, ['token' => $token], 200);
    }

    private function json(ResponseInterface $response, array $data, int $status): ResponseInterface
    {
        $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

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

config/container.php を更新します。 Repository / Service / Action の定義を追加します。

<?php
declare(strict_types=1);

use App\Action\LoginAction;
use App\Action\RegisterUserAction;
use App\Handler\JsonErrorHandler;
use App\Repository\UserRepository;
use App\Service\JwtService;
use DI\ContainerBuilder;

return static function (ContainerBuilder $containerBuilder): void {
    $containerBuilder->addDefinitions([
        PDO::class => function (): PDO {
            $host = $_ENV['DB_HOST'] ?? 'db';
            $port = $_ENV['DB_PORT'] ?? '5432';
            $dbname = $_ENV['POSTGRES_DB'] ?? 'app_db';
            $user = $_ENV['POSTGRES_USER'] ?? 'app_user';
            $pass = $_ENV['POSTGRES_PASSWORD'] ?? 'app_pass';

            $dsn = "pgsql:host={$host};port={$port};dbname={$dbname}";

            return new PDO($dsn, $user, $pass, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
        },

        'jwt.secret' => function (): string {
            return $_ENV['JWT_SECRET'] ?? 'change-me-to-random-string';
        },

        JwtService::class => function ($c): JwtService {
            return new JwtService($c->get('jwt.secret'));
        },

        UserRepository::class => DI\autowire(),
        RegisterUserAction::class => DI\autowire(),
        LoginAction::class => DI\autowire(),
        JsonErrorHandler::class => DI\autowire(),
    ]);
};

app/routes.php を更新します。 POST /api/registerPOST /api/login を追加します。

<?php
declare(strict_types=1);

use App\Action\LoginAction;
use App\Action\RegisterUserAction;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\App;

return static function (App $app): void {
    $app->get('/health', function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
        $response->getBody()->write(json_encode([
            'status' => 'ok',
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

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

    $app->post('/api/register', RegisterUserAction::class);
    $app->post('/api/login', LoginAction::class);
};

詰まり時:

  • 登録できない: 必須項目(name / email / password)がリクエストに含まれているか確認
  • ログインできない: password_hash() で保存し、password_verify() で照合しているか確認
  • DI 解決に失敗: config/container.php の定義と use 宣言を見直す

コードのポイント

① JWT ペイロードの構造(JwtService.php

        $payload = [
            'sub' => $userId,
            'iat' => $now,
            'exp' => $now + $this->ttl,
        ];

        return JWT::encode($payload, $this->secret, $this->algorithm);

sub(subject)にユーザー ID を入れ、exp で有効期限を設定する。exp がないと token が無期限になるため必ず指定すること。decode() は検証失敗や期限切れで例外を投げるので、呼び出し側で try/catch する。

② 重複メール確認とパスワードハッシュ化(RegisterUserAction.php

        if ($this->userRepository->findByEmail($email) !== null) {
            return $this->json($response, ['error' => ['message' => 'Email already registered.', 'status' => 409]], 409);
        }

        $passwordHash = password_hash($password, PASSWORD_DEFAULT);

先に findByEmail() で重複を確認し、重複があれば 409 を返す。password_hash()PASSWORD_DEFAULT でハッシュ化し、平文のまま DB に保存しない。

③ パスワード照合と JWT 発行(LoginAction.php

        if ($user === null || !password_verify($password, $user['password_hash'])) {
            return $this->json($response, ['error' => ['message' => 'Invalid email or password.', 'status' => 401]], 401);
        }

        $token = $this->jwtService->encode((int) $user['id']);

password_verify() でハッシュと照合し、ユーザー不在と不一致を同じ 401 に寄せて情報を漏らさない。照合が通れば jwtService->encode() でユーザー ID を sub に詰めた JWT を発行する。

6. JWT middleware で GET /api/me を保護する

Authorization: Bearer を検証する middleware を追加し、保護 API を成立させます。

src/Middleware/JwtAuthMiddleware.php を作成します。 Authorization ヘッダから Bearer token を取り出し、JWT を検証して対応ユーザーを request attribute に載せます。

<?php
declare(strict_types=1);

namespace App\Middleware;

use App\Repository\UserRepository;
use App\Service\JwtService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Factory\ResponseFactory;
use Throwable;

final class JwtAuthMiddleware implements MiddlewareInterface
{
    public function __construct(
        private JwtService $jwtService,
        private UserRepository $userRepository,
        private ResponseFactory $responseFactory,
    ) {
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $header = $request->getHeaderLine('Authorization');

        if ($header === '' || !str_starts_with($header, 'Bearer ')) {
            return $this->unauthorized('Token required.');
        }

        $token = substr($header, 7);

        try {
            $decoded = $this->jwtService->decode($token);
        } catch (Throwable) {
            return $this->unauthorized('Invalid token.');
        }

        $user = $this->userRepository->findById((int) $decoded->sub);

        if ($user === null) {
            return $this->unauthorized('User not found.');
        }

        $request = $request->withAttribute('currentUser', $user);

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

    private function unauthorized(string $message): ResponseInterface
    {
        $payload = [
            'error' => [
                'message' => $message,
                'status' => 401,
            ],
        ];

        $response = $this->responseFactory->createResponse(401);
        $response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

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

トークン未指定、不正トークン、存在しないユーザーの 3 パターンをすべて 401 に寄せています。 request attribute に currentUser を載せることで、Action 側は token 解析の詳細を持ち込まずにユーザー情報を参照できます。

src/Action/MeAction.php を作成します。

<?php
declare(strict_types=1);

namespace App\Action;

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

final class MeAction
{
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $user = $request->getAttribute('currentUser');

        $payload = [
            'user' => [
                'id' => $user['id'],
                'name' => $user['name'],
                'email' => $user['email'],
            ],
        ];

        $response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

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

config/container.php を更新します。 JwtAuthMiddlewareMeAction の定義を追加します。

<?php
declare(strict_types=1);

use App\Action\LoginAction;
use App\Action\MeAction;
use App\Action\RegisterUserAction;
use App\Handler\JsonErrorHandler;
use App\Middleware\JwtAuthMiddleware;
use App\Repository\UserRepository;
use App\Service\JwtService;
use DI\ContainerBuilder;

return static function (ContainerBuilder $containerBuilder): void {
    $containerBuilder->addDefinitions([
        PDO::class => function (): PDO {
            $host = $_ENV['DB_HOST'] ?? 'db';
            $port = $_ENV['DB_PORT'] ?? '5432';
            $dbname = $_ENV['POSTGRES_DB'] ?? 'app_db';
            $user = $_ENV['POSTGRES_USER'] ?? 'app_user';
            $pass = $_ENV['POSTGRES_PASSWORD'] ?? 'app_pass';

            $dsn = "pgsql:host={$host};port={$port};dbname={$dbname}";

            return new PDO($dsn, $user, $pass, [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]);
        },

        'jwt.secret' => function (): string {
            return $_ENV['JWT_SECRET'] ?? 'change-me-to-random-string';
        },

        JwtService::class => function ($c): JwtService {
            return new JwtService($c->get('jwt.secret'));
        },

        UserRepository::class => DI\autowire(),
        RegisterUserAction::class => DI\autowire(),
        LoginAction::class => DI\autowire(),
        MeAction::class => DI\autowire(),
        JwtAuthMiddleware::class => DI\autowire(),
        JsonErrorHandler::class => DI\autowire(),
    ]);
};

app/routes.php を更新します。 route group に middleware を付けることで、保護対象が増えても崩れにくい構成にします。

<?php
declare(strict_types=1);

use App\Action\LoginAction;
use App\Action\MeAction;
use App\Action\RegisterUserAction;
use App\Middleware\JwtAuthMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\App;
use Slim\Routing\RouteCollectorProxy;

return static function (App $app): void {
    $app->get('/health', function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
        $response->getBody()->write(json_encode([
            'status' => 'ok',
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

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

    $app->post('/api/register', RegisterUserAction::class);
    $app->post('/api/login', LoginAction::class);

    $app->group('/api', function (RouteCollectorProxy $group) {
        $group->get('/me', MeAction::class);
    })->add(JwtAuthMiddleware::class);
};

route ごとではなく group に middleware を付けると、今後 GET /api/profilePOST /api/tasks のような保護エンドポイントを追加するときも、group に入れるだけで済みます。

詰まり時:

  • Authorization ヘッダを拾えない: Bearer の接頭辞(末尾にスペース)を確認
  • token 検証エラー: .envJWT_SECRETJwtService で使う secret が一致しているか確認

コードのポイント

① Bearer トークン抽出と検証(JwtAuthMiddleware.php

        if ($header === '' || !str_starts_with($header, 'Bearer ')) {
            return $this->unauthorized('Token required.');
        }

        $token = substr($header, 7);

        try {
            $decoded = $this->jwtService->decode($token);
        } catch (Throwable) {
            return $this->unauthorized('Invalid token.');
        }

str_starts_with($header, 'Bearer ') で接頭辞を確認し、substr($header, 7) でスキームを取り除いたトークン文字列を取り出す。decode() は不正トークンや期限切れで例外を投げるため catch (Throwable) で受けて 401 を返す。

② 認証済みユーザーを request attribute に載せる(JwtAuthMiddleware.php

        $request = $request->withAttribute('currentUser', $user);

withAttribute() は PSR-7 のイミュータブル API なので戻り値の $request を使わないとデータが消える。この 1 行で Action 側は $request->getAttribute('currentUser') だけでユーザー情報を取得でき、JWT 解析の詳細を持ち込まずに済む。

③ route group へのミドルウェア適用(app/routes.php

    $app->group('/api', function (RouteCollectorProxy $group) {
        $group->get('/me', MeAction::class);
    })->add(JwtAuthMiddleware::class);

group()add() を連鎖させることで、group 内の全ルートに middleware を一括適用できる。保護対象のエンドポイントが増えた場合も $group->get(...) を追加するだけでよい。

7. curl で成功系と失敗系を確認する

php -S を起動した別ターミナルはそのままにしておいてください。 まず register → login → me の順で成功系を通し、その後に失敗系を確認します。

ユーザー登録

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

実行結果例:

HTTP/1.1 201 Created
Content-Type: application/json

{"user":{"id":1,"name":"Taro","email":"taro@example.com"}}
register が 201 Created を返した画面

ログイン

curl -i -X POST http://localhost:8080/api/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"taro@example.com","password":"secret123"}'

実行結果例:

HTTP/1.1 200 OK
Content-Type: application/json

{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."}
login が token を返した画面 返ってきた token を控えておいてください。次の `GET /api/me` で使います。

認証済みユーザー情報の取得

curl -i http://localhost:8080/api/me \
  -H 'Authorization: Bearer <ログインで取得したtoken>'

実行結果例:

HTTP/1.1 200 OK
Content-Type: application/json

{"user":{"id":1,"name":"Taro","email":"taro@example.com"}}
Authorization Bearer 付きの me が 200 を返した画面

失敗系の確認

重複登録(409):

curl -i -X POST http://localhost:8080/api/register \
  -H 'Content-Type: application/json' \
  -d '{"name":"Taro","email":"taro@example.com","password":"secret123"}'
HTTP/1.1 409 Conflict
Content-Type: application/json

{"error":{"message":"Email already registered.","status":409}}
重複登録で 409 Conflict を返した画面

パスワード不一致(401):

curl -i -X POST http://localhost:8080/api/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"taro@example.com","password":"wrong"}'
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{"error":{"message":"Invalid email or password.","status":401}}
パスワード不一致で 401 Unauthorized を返した画面

トークン未指定(401):

curl -i http://localhost:8080/api/me
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{"error":{"message":"Token required.","status":401}}
token 未指定で 401 Unauthorized を返した画面

不正トークン(401):

curl -i http://localhost:8080/api/me \
  -H 'Authorization: Bearer invalid-token'
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{"error":{"message":"Invalid token.","status":401}}
不正 token で 401 Unauthorized を返した画面

存在しないパス(404):

curl -i http://localhost:8080/not-found
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":{"message":"Not Found","status":404}}
存在しないパスで 404 Not Found を返した画面

DB側でも確認する

docker compose exec db psql -U app_user -d app_db -c "SELECT id, email, created_at FROM users;"
 id |       email        |          created_at
----+--------------------+-------------------------------
  1 | taro@example.com   | 2026-03-08 12:00:00.000000+00
users テーブルを SELECT して確認した画面

curl だけでなく DB 側でも確認することで、JWT 認証だけでなく PostgreSQL との接続が成立していることを見せられます。

詰まり時:

  • 401 しか返らない: login 応答の token をそのまま Bearer ヘッダに渡しているか確認
  • 409 になる: 同じメールで再登録していないか確認
  • 500 + Provided key is too short: .envJWT_SECRET が短すぎる。長いランダム文字列へ更新し、docker compose up -d --force-recreate app の後に php -S を起動し直す

8. まとめと次の一歩

本記事で、次の状態を達成しました。

  • Slim 4 + PostgreSQL + JWT で認証付き API を最小構成で通せた
  • POST /api/register でユーザーを登録し、password_hash() でハッシュ化して保存した
  • POST /api/loginpassword_verify() による認証を行い、JWT を発行した
  • GET /api/meAuthorization: Bearer と route group middleware で保護した
  • 成功系だけでなく、重複登録・認証失敗・未認証・不正トークン・404 を JSON で統一した

本番環境で運用する場合は、HTTPS を前提としてください。HTTP 上の Bearer token は盗聴リスクがあります。

起動不能になった場合は 2 章へ、DB の問題は 3 章へ、Slim の起動は 4 章へ、登録・ログインは 5 章へ、保護 API は 6 章へ、検証は 7 章へ戻って確認してください。

次の一歩:

  1. 保護対象リソースの CRUD(例: tasks テーブル)を追加する
  2. refresh token を導入し、access token の短命化と再発行を実現する
  3. PHPUnit で API の結合テストを書く

シリーズ 3/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最小構成) 現在の記事