Slim4 を使ってみたメモ

とある社内用のツールで Silex を使っていたのですが、随分前に DEPRECATED になっている ので、Slim4 にリプレースしました。

Silex からの移行なら Symfony Flex では? という気もしますが特に深い理由はありません。もともと極小さいアプリでフレームワークなんて何でも良い(無くても良い)ぐらいのものなので、たまたま Slim4 を使ってみよー、と思っただけの理由です。

環境

  • PHP 7.4.10
  • slim/slim 4.5.0
  • slim/slim-skeleton 4.1.0

インストール

composer でスケルトンからプロジェクトを作ると手っ取り早いです。

composer create-project slim/slim-skeleton my-slim-app
cd my-slim-app
php -S localhost:8080 -t public public/index.php

スケルトンだと主に次のようなパッケージ構成になります。これらはカスタマイズして差し替えることも可能です。

PSR-7 や PSR-15 や PSR-17 は Slim のパッケージで実装されています。

PSR-7/PSR-17 は本体の slim/slim とは別の slim/psr7 で実装されています。これも別の実装、例えば nyholm/psr7 とか guzzlehttp/psr7 とか laminas/laminas-diactoros に差し替えることも可能です。

error logging

スケルトンの素のままだとエラーでもログがでないので index.php の下記の箇所を変更します。

<?php
// false を true に変更
$errorMiddleware = $app->addErrorMiddleware($displayErrorDetails, true, true);
$errorMiddleware->setDefaultErrorHandler($errorHandler);

また、ログが PHP の error_log 関数に行ってしまうので、Monolog に来るように下記の箇所も変更します。

<?php
// LoggerInterface のインスタンスを HttpErrorHandler の第3引数に渡す
$logger = $container->get(LoggerInterface::class);
$errorHandler = new HttpErrorHandler($callableResolver, $responseFactory, $logger);

Error Handling Middleware - Slim Framework によると $app->addErrorMiddleware() の4番目の引数に $logger を渡しても良さそうですが、そこで渡した $loggerErrorMiddleware がデフォルトのエラーハンドラを作成するときに渡されるだけのものです。

スケルトンではエラーハンドラは index.php で作られたものが ErrorMiddleware に渡されているので、そっちに $logger を渡す必要があります。ここわかりにくかったです。

slim/twig-view

素のままだとテンプレートエンジンがありません。GitHub の Slim Framework の Organization を見ると PHP-ViewTwig-View とが見つかりました。

PHP-View はいわゆる php をテンプレートとして使うやつです。最低限のレイアウト機能があるだけのものすごいシンプルなものです。

Twig-View はいわゆる Twig です。こっちを使います。

composer require slim/twig-view

アプリケーションのミドルウェアスタックに TwigMiddleware を追加します。

<?php
// app/middleware.php
use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;

return function (App $app) {
    $app->add(SessionMiddleware::class);
    $app->add(TwigMiddleware::createFromContainer($app, Twig::class));
};

Slim\Views\Twig の設定とインスタンスをコンテナで定義します。

<?php
// app/settings.php
return function (ContainerBuilder $containerBuilder) {
    $containerBuilder->addDefinitions([
        'settings' => [
            'twig' => [
                'debug' => true,
                'strict_variables' => true,
                'cache' => __DIR__ . '/../var/cache/twig',
            ],
        ],
    ]);
};
<?php
// app/dependencies.php
use Slim\Views\Twig;

return function (ContainerBuilder $containerBuilder) {
    $containerBuilder->addDefinitions([
        Twig::class => function (ContainerInterface $container) {
            $settings = $container->get('settings');
            return Twig::create(__DIR__ . '/../templates', $settings['twig']);
        },
    ]);
};

コンテナから Twig のインスタンスを取り出してテンプレートをレンダリングできます。

<?php
// app/routes.php
return function (App $app) {
    $app->get('/home', function (Request $request, Response $response) use ($app) {
        $twig = $app->getContainer()->get(Twig::class);
        return $twig->render($response, 'home.twig', [
            'name' => 'ore',
        ]);
    });
};

実際にはアクションをクラス化してコンストラクタインジェクションのほうが良いですね。

<?php
class HomeAction
{
    private Twig $twig;

    public function __construct(Twig $twig)
    {
        $this->twig = $twig;
    }

    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        return $this->twig->render($response, 'home.twig', [
            'name' => 'ore',
        ]);
    }
}

いくつかの Slim 特有のテンプレート関数が使用可能です。url_for() などのルート名から URL を返したりなどの、よくあるやつです。

ところで、テンプレートエンジンを使うためになぜミドルウェアを登録する必要があるの? と思いました。いくつかのテンプレート関数で現在のリクエストの URL が必要なため(full_url_for() とか)、ミドルウェアのタイミングでテンプレート関数が Twig に登録されるようになっているためでした。

なので TwigMiddleware をミドルウェアスタックに登録しなければ Slim 特有のテンプレート関数は使えません。

アクションの中でルート名を元に URL を得る

Twig のテンプレートの中なら url_for() テンプレート関数でルート名を元に URL が得られますが、アクションの中でやる場合はリクエストオブジェクトから RouteContext を取り出して使います。

<?php
use Slim\Routing\RouteContext;

class HomeAction
{
    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $location = RouteContext::fromRequest($request)->getRouteParser()->fullUrlFor($request->getUri(), 'home');
        return $response->withStatus(303)->withHeader('location', $location);
    }
}

これはなかなかにめんどくさいですね。次のようなメソッドを持ったトレイトとか作っておくと便利でしょうか。

<?php
private function fullUrlFor(
    ServerRequestInterface $request,
    string $routeName,
    array $data = [],
    array $queryParams = []
): string {
    return RouteContext::fromRequest($request)->getRouteParser()
        ->fullUrlFor($request->getUri(), $routeName, $data, $queryParams);
}

private function redirect(ResponseInterface $response, string $location): ResponseInterface
{
    return $response->withStatus(303)->withHeader('location', $location);
}

次のように使います。

<?php
return $this->redirect($response, $this->fullUrlFor($request, 'home'));

slim/http

素の slim/psr7 だと(というか PSR-7 だと)必要最低限のメソッドしか持っておらず、例えばリクエストメソッドが POST かどうかを確認するためだけにこれだけのコードが必要です。

<?php
$isPost = strtoupper($request->getMethod()) === 'POST';

slim/http は PSR-7 のインタフェースをラップし、いくつかの便利なメソッドを追加します。

スケルトンのコードなら composer で入れておくだけで自動で検出して使用されます。

composer require slim/http

アクションで slim/http のリクエスト・レスポンスが利用できます。

<?php
use Slim\Http\Response;
use Slim\Http\ServerRequest;

return function (App $app) {
    $app->map(['GET', 'POST'], '/', function (ServerRequest $request, Response $response) {
        if ($request->isPost()) {
            return $response->withRedirect('http://example.com/', 303);
        }
        return $response->withJson(['hello' => 'world!']);
    });
};

ただ、レスポンスはともかく(アクションがディスパッチされる直前に生成されるため)、リクエストの方はミドルウェアで別のクラスに置き換えられているとダメですね。

<?php
return function (App $app) {
    $app->map(['GET', 'POST'], '/', function (ServerRequest $request, Response $response) {
        if ($request->isPost()) {
            return $response->withRedirect('http://example.com/', 303);
        }
        return $response->withJson(['hello' => 'world!']);
    })->add(function (ServerRequestInterface $request, RequestHandlerInterface $handler) {
        // laminas-diactoros のリクエストオブジェクトに置き換える
        $request = new \Laminas\Diactoros\ServerRequest(
            $request->getServerParams(),
            $request->getUploadedFiles(),
            $request->getUri(),
            $request->getMethod(),
            $request->getBody(),
            $request->getHeaders(),
            $request->getCookieParams(),
            $request->getQueryParams(),
            $request->getParsedBody(),
            $request->getProtocolVersion()
        );
        return $handler->handle($request);
    });
};

普通は $request->withXXX() で自身を clone したインスタンスを使うので問題ないはずですが、↑のようなコードは PSR でダメということになっていましたっけ? であれば良いんですけど。

slim/csrf

いわゆる CSRF 対策です。

composer require slim/csrf

ミドルウェアスタックに追加します。

<?php
// app/middleware.php
use Slim\Csrf\Guard;

return function (App $app) {
    $app->add(Guard::class);
    $app->add(SessionMiddleware::class);
    $app->add(TwigMiddleware::createFromContainer($app, Twig::class));
};

Guard クラスが ResponseFactoryInterface に依存しており、スケルトンのコードだと解決できないため DI の定義も必要です。

<?php
// app/dependencies.php
return function (ContainerBuilder $containerBuilder) {
    $containerBuilder->addDefinitions([
        Guard::class => function (ContainerInterface $container) {
            return new Guard(new ResponseFactory());
        },
    ]);
};

これでも一応動きますが、本当なら ResponseFactory をこんなところで作るべきではなく、App インスタンスが持っている ResponseFactory を使うべきです。がしかし、App インスタンスはコンテナに入っていないのでここからでは参照できません。

App インスタンスもコンテナに入れれば良いですね(ResponseFactoryInterface をコンテナに入れるのでも良いと思います)。

<?php
// app/dependencies.php
return function (ContainerBuilder $containerBuilder) {
    $containerBuilder->addDefinitions([
        App::class => function (ContainerInterface $container) {
            return AppFactory::createFromContainer($container);
        },
        Guard::class => function (App $app) {
            return new Guard($app->getResponseFactory());
        },
    ]);
};
<?php
// public/index.php
$app = $container->get(App::class);

フォームへの埋め込みは次のようになります。

<?php
// アクション
return $this->twig->render($response, 'home.twig', [
    'csrf'   => [
        'keys' => [
            'name'  => $this->csrf->getTokenNameKey(),
            'value' => $this->csrf->getTokenValueKey(),
        ],
        'name'  => $request->getAttribute($this->csrf->getTokenNameKey()),
        'value' => $request->getAttribute($this->csrf->getTokenValueKey()),
    ]
]);

// テンプレート
<form method="post" action="{{ url_for('home.post') }}">
    <button type="submit">Submit</button>
    <input type="hidden" name="{{ csrf.keys.name }}" value="{{ csrf.name }}">
    <input type="hidden" name="{{ csrf.keys.value }}" value="{{ csrf.value }}">
</form>

めちゃめちゃ記述量が多いですね・・・Twig のテンプレート関数とかにすれば良いと思います。

なお、素のままだとリクエストの都度新しいトークンが作られ、直近の200個までセッションに保持されます。 次のように Guard のインスタンスで設定すれば1つだけ作られたトークンが使われ続けるようになります。

<?php
// app/dependencies.php
Guard::class => function (App $app) {
    return (new Guard($app->getResponseFactory()))->setPersistentTokenMode(true);
},

Route strategies

アクションをディスパッチする方法がカスタマイズできます。

例えば次のようなコードを使えば、コンテナからアクションのメソッドにインジェクションしたり、ルートパラメータを名前でインジェクションしたりできます。

<?php
// MyInvocationStrategy.php
class MyInvocationStrategy implements \Slim\Interfaces\InvocationStrategyInterface
{
    private \Invoker\Invoker $invoker;

    public function __construct(ContainerInterface $container)
    {
        $resolver = new \Invoker\ParameterResolver\ResolverChain([
            new \Invoker\ParameterResolver\Container\TypeHintContainerResolver($container),
            new \Invoker\ParameterResolver\TypeHintResolver(),
            new \Invoker\ParameterResolver\AssociativeArrayResolver(),
        ]);
        $this->invoker = new \Invoker\Invoker($resolver);
    }

    public function __invoke(
        callable $callable,
        ServerRequestInterface $request,
        ResponseInterface $response,
        array $routeArguments
    ): ResponseInterface {
        return $this->invoker->call($callable, [
            ServerRequestInterface::class => $request,
            ResponseInterface::class => $response,
            get_class($request) => $request,
            get_class($response) => $response,
        ] + $routeArguments);
    }
}
<?php
// public/index.php
$app->getRouteCollector()->setDefaultInvocationStrategy($container->get(MyInvocationStrategy::class));
<?php
// ViewUserAction.php
class ViewUserAction
{
    public function __invoke(ServerRequest $request, Response $response, UserRepository $repository, string $id): Response
    {
        $user = $repository->findUserOfId((int)$id);
        // ...
        return $response;
    }
}

まあそこまでしてメソッドインジェクションしなくていいと思います。1クラスに1アクションになっているならコンストラクタインジェクションでも極端な量の依存をコンストラクタ引数に書いたりすることも無いでしょう。

環境変数でモード切り替え

スケルトンだと production とか development とかのモード切替の概念がないため、自前で実装する必要があります。

次のように適当な環境変数をコンテナの定義で使うとよいでしょう。

<?php
use function DI\get;

return function (ContainerBuilder $containerBuilder) {
    $containerBuilder->addDefinitions([
        'debug' => (bool)getenv('APP_DEBUG'),
        'settings' => [
            'displayErrorDetails' => get('debug'),
            'twig' => [
                'debug' => get('debug'),
                'strict_variables' => true,
                'cache' => __DIR__ . '/../var/cache/twig',
            ],
        ],
    ]);
};

コンテナとルートの定義をキャッシュ

プロダクションではコンテナとルートの定義をキャッシュすることでパフォーマンスの向上が期待できます。

<?php
// public/index.php

// コンテナ定義のキャッシュ
$containerBuilder = new ContainerBuilder();
$containerBuilder->enableCompilation(__DIR__ . '/../var/cache');

// ルート定義のキャッシュ
$app->getRouteCollector()->setCacheFile(__DIR__ . '/../var/cache/routes.php');

ただスケルトンでは有効・無効を切り替えられるような仕組みがないため、環境変数で分岐するなどの対応が必要です。

もしくは、キャッシュファイルを作成するだけのスクリプトを作成し、手動でキャッシュファイルを作成していればそれを使うし、なければキャッシュ無効にする、という振り分けも良さそうです。

まず public/index.php からコンテナと App インスタンスを作成してルート定義を読み込むところまでを別ファイルに切り出します。このファイルはクロージャーを返し、引数によってキャッシュファイルを作成するかどうかを制御できます。

<?php
// app/app.php

// $compile = true で実行するとキャッシュファイルを作り直す
return function (bool $compile = false) {

    $containerBuilder = new ContainerBuilder();

    $cacheDir = __DIR__ . '/../var/cache';
    $containerCacheFile = "$cacheDir/CompiledContainer.php";

    if ($compile) {
        // 既存のキャッシュファイルを削除して再作成
        if (file_exists($containerCacheFile)) {
            unlink($containerCacheFile);
        }
        $containerBuilder->enableCompilation($cacheDir);
    } else {
        // キャッシュファイルがあれば有効にする
        if (file_exists($containerCacheFile)) {
            $containerBuilder->enableCompilation($cacheDir);
        }
    }

    // ... コンテナ定義、ルート定義、ミドルウェア定義、などの読み込み ...

    $routeCacheFile = "$cacheDir/routes.php";

    if ($compile) {
        // 既存のキャッシュファイルを削除して再作成
        if (file_exists($routeCacheFile)) {
            unlink($routeCacheFile);
        }
        $app->getRouteCollector()->setCacheFile($routeCacheFile);

        // キャッシュファイルを作成するためにルート解決を実行
        $app->getRouteResolver()->computeRoutingResults('/', 'GET');
    } else {
        // キャッシュファイルがあれば有効にする
        if (file_exists($routeCacheFile)) {
            $app->getRouteCollector()->setCacheFile($routeCacheFile);
        }
    }

    return $app;
};

public/index.php ではそのファイルから App インスタンスを取得し、残りの処理を実行します。

<?php
// public/index.php
$app = (require __DIR__ . '/../app/app.php')();
assert($app instanceof App);

// ... ShutdownHandler や ErrorMiddleware を登録 ...

$response = $app->handle($request);
$responseEmitter = new ResponseEmitter();
$responseEmitter->emit($response);

キャッシュファイルを作成するスクリプトでは引数を true にして App インスタンスを取得します。

<?php
// scripts/optimize.php
require __DIR__ . '/../vendor/autoload.php';
(require __DIR__ . '/../app/app.php')(true);

php scripts/optimize.php でキャッシュファイルが作成されればキャッシュ有効モードに、キャッシュファイルを削除すればキャッシュ無効モードになります。

さいごに

スケルトンをそのままでは扱いにくいところがあり、ソースを見つつ手を加えていく必要がありました。ただ、ソース全部読んだとしてもたいした分量ではないし、素直に PSR-7/PSR-15/PSR-17 に乗っかっているのでわかりやすいと思いました。

フルスタックフレームワークは自由がなさすぎる、かといってオレオレフレームワークは秩序がなさすぎる、というときには Slim4 もアリなんじゃないでしょうか。いろいろ肉付けしていくとオレオレ染みてくる気もするけど。

もっとも初手の速さは Laravel のようなフルスタックの方が速いので、とりあえず動くなにかを作るのには向かないかもです。自分用に Slim4 ベースのボイラーテンプレートを作っておけばまた違うかもしれないですけど(それってもうオレオレのようなものなのではという気もする)。