対象読者: WSL2 + Docker で PHP 開発中で、Laravel などでは認証付き API を触ったことがあるが、Slim 4 で同等の最小構成を自力で組みたい方
本記事では、Slim 4 + PostgreSQL + JWT で「ユーザー登録・ログイン・保護API」を最小構成で通します。
完成時のエンドポイントは POST /api/register、POST /api/login、GET /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/meがAuthorization: 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-jwt で HS256 を使う場合、secret が短すぎると Provided key is too short で失敗します。
記事どおりに手を動かす段階でも、JWT_SECRET は十分長いランダム文字列にしてください。
自分で生成する場合は、例えば次のように作れます。
openssl rand -hex 32
このコマンドは 64 文字の16進文字列を返します。 本番ではサンプル値をそのまま使わず、生成した値へ置き換えてください。
composer.json を作成します。
autoload.psr-4 に App\\ => 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 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"
詰まり時:
- テーブルが見えない:
docker compose down -vしてからdocker compose up -d --build - 認証失敗:
.envのPOSTGRES_USER/POSTGRES_PASSWORDとcompose.ymlのenv_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"}
詰まり時:
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 は displayErrorDetails が true のときのみ詳細を出力する。
5. register と login を実装する
ユーザー登録とログインを実装し、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/register と POST /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 を更新します。
JwtAuthMiddleware と MeAction の定義を追加します。
<?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/profile や POST /api/tasks のような保護エンドポイントを追加するときも、group に入れるだけで済みます。
詰まり時:
Authorizationヘッダを拾えない:Bearerの接頭辞(末尾にスペース)を確認- token 検証エラー:
.envのJWT_SECRETとJwtServiceで使う 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"}}
ログイン
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..."}
返ってきた 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"}}
失敗系の確認
重複登録(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}}
パスワード不一致(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):
curl -i http://localhost:8080/api/me
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"error":{"message":"Token required.","status":401}}
不正トークン(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}}
存在しないパス(404):
curl -i http://localhost:8080/not-found
HTTP/1.1 404 Not Found
Content-Type: application/json
{"error":{"message":"Not Found","status":404}}
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
curl だけでなく DB 側でも確認することで、JWT 認証だけでなく PostgreSQL との接続が成立していることを見せられます。
詰まり時:
401しか返らない: login 応答の token をそのままBearerヘッダに渡しているか確認409になる: 同じメールで再登録していないか確認500+Provided key is too short:.envのJWT_SECRETが短すぎる。長いランダム文字列へ更新し、docker compose up -d --force-recreate appの後にphp -Sを起動し直す
8. まとめと次の一歩
本記事で、次の状態を達成しました。
- Slim 4 + PostgreSQL + JWT で認証付き API を最小構成で通せた
POST /api/registerでユーザーを登録し、password_hash()でハッシュ化して保存したPOST /api/loginでpassword_verify()による認証を行い、JWT を発行したGET /api/meをAuthorization: Bearerと route group middleware で保護した- 成功系だけでなく、重複登録・認証失敗・未認証・不正トークン・404 を JSON で統一した
本番環境で運用する場合は、HTTPS を前提としてください。HTTP 上の Bearer token は盗聴リスクがあります。
起動不能になった場合は 2 章へ、DB の問題は 3 章へ、Slim の起動は 4 章へ、登録・ログインは 5 章へ、保護 API は 6 章へ、検証は 7 章へ戻って確認してください。
次の一歩:
- 保護対象リソースの CRUD(例: tasks テーブル)を追加する
- refresh token を導入し、access token の短命化と再発行を実現する
- PHPUnit で API の結合テストを書く