とある社内用のツールで 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
スケルトンだと主に次のようなパッケージ構成になります。これらはカスタマイズして差し替えることも可能です。
- ルーター nikic/fast-route
- コンテナ php-di/php-di
- ロギング monolog/monolog
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
を渡しても良さそうですが、そこで渡した $logger
は ErrorMiddleware
がデフォルトのエラーハンドラを作成するときに渡されるだけのものです。
スケルトンではエラーハンドラは index.php
で作られたものが ErrorMiddleware
に渡されているので、そっちに $logger
を渡す必要があります。ここわかりにくかったです。
slim/twig-view
素のままだとテンプレートエンジンがありません。GitHub の Slim Framework の Organization を見ると PHP-View と Twig-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 ベースのボイラーテンプレートを作っておけばまた違うかもしれないですけど(それってもうオレオレのようなものなのではという気もする)。