Create templated 404 page in Slim 4 with CallableResolver
In Slim 4 Skeleton, the 404 response handled in App\Application\Handlers\HttpErrorHandler::respond()
which check against Slim\Exception\HttpNotFoundException
. We can create a templated 404 page for it with utilize Slim\CallableResolver
via callableResolver
property which can resolve the callable handler. I assume that we are using Twig template engine, and already has setup of Twig service like in my previous post.
We can create a not found handler like the following in src/Application/Handlers/NotFoundHandler.php
<?php // src/Application/Handlers/NotFoundHandler.php declare(strict_types=1); namespace App\Application\Handlers; use Psr\Http\Message\ResponseInterface; use Slim\Views\Twig; use function compact; class NotFoundHandler { private $view; public function __construct(Twig $view) { $this->view = $view; } public function __invoke( ResponseInterface $response, string $message ): ResponseInterface { return $this->view->render($response, '404.html.twig', compact('message')); } }
We can create a view based on it like the following at templates/404.html.twig
:
{# templates/404.html.twig #} {% extends "layout.html.twig" %} {% block title '404 - '~parent() %} {% block body %} {{ message }} {% endblock %}
Now, in App\Application\Handlers\HttpErrorHandler::respond()
, we can check when $exception instanceof HttpNotFoundException to make a self called resolved NotFoundHandler.
// src/Application/Handlers/HttpErrorHandler.php // ... protected function respond(): Response { // ... if ($exception instanceof HttpNotFoundException) { $response = $this->responseFactory->createResponse($statusCode); return ($this->callableResolver->resolve(NotFoundHandler::class)( $response, $exception->getMessage() )); } // ... } // ...
So, when the Slim\Exception\HttpNotFoundException
thrown, it will shown the 404 page with brought the message passed into it like the following:
Bonus
We can create a handling against Request as well, eg: show 404 templated page only when request doesn’t has Accept: application/json or X-Requested-With:XmlHttpRequest header, so, we can modify like the following:
// src/Application/Handlers/HttpErrorHandler.php // ... protected function respond(): Response { // ... if ($exception instanceof HttpNotFoundException) { $isAppJsonAccept = $this->request->getHeaderLine('Accept') === 'application/json'; $isXmlHttpRequest = $this->request->getHeaderLine('X-Requested-With') === 'XmlHttpRequest'; if (! $isAppJsonAccept && ! $isXmlHttpRequest) { $response = $this->responseFactory->createResponse($statusCode); return ($this->callableResolver->resolve(NotFoundHandler::class)( $response, $exception->getMessage() )); } // already return early, no need else $error->setType(ActionError::RESOURCE_NOT_FOUND); } // ... } // ...
So, for example, when called via ajax, it will show like the following:
Create “Abstract Factory” service in PHP-DI with RequestedEntry and Wildcards
If you’re familiar with Zend Framework’s servicemanager, you may already used the abstract factory which acts as limbo when service not registered. When the pattern checked is matched, it will try to create service based on it, automatically.
We can do that in PHP-DI as well. For example, we are experimenting with my previous post on DDD in slim 4 framework:
├───Application ├───Domain │ ├───DomainException │ │ DomainException.php │ │ DomainRecordNotFoundException.php │ │ │ ├───Post │ │ Post.php │ │ PostNotFoundException.php │ │ PostRepository.php │ └───Infrastructure └───Persistence ├───Post │ ZendDbPostRepository.php
We are going to create service with pattern “App\Domain\Post\PostRepository” from App\Infrastructure\Persistence
namespace with as an object from “App\Infrastructure\Persistence\Post\ZendDbPostRepository”. In PHP-DI, we can combine both RequestedEntry and Wildcards definitions, the service definition will be like the following:
<?php declare(strict_types=1); use DI\ContainerBuilder; use Psr\Container\ContainerInterface; use Zend\Db\Adapter\AdapterInterface; use Zend\Db\ResultSet\HydratingResultSet; use Zend\Db\TableGateway\TableGateway; use Zend\Hydrator\ObjectPropertyHydrator; use DI\Factory\RequestedEntry; return function (ContainerBuilder $containerBuilder) { $containerBuilder->addDefinitions([ 'App\Domain\*\*Repository' => function (RequestedEntry $entry, ContainerInterface $c) { // get entity class name, // eg: "Post" by service named "App\Domain\Post\PostRepository" preg_match( '/(?<=App\\\\Domain\\\\)([A-Z][a-z]{1,})(?=\\\\\1Repository)/', $entry->getName(), $matches ); $entity = current($matches); $fullEntityClass = 'App\Domain' . str_repeat('\\' . $entity, 2); $fullRepoClass = 'App\Infrastructure\Persistence' . '\\' . $entity . '\ZendDb' . $entity . 'Repository'; $tableGateway = new TableGateway( $fullEntityClass::TABLE, $c->get(AdapterInterface::class), null, new HydratingResultSet(new ObjectPropertyHydrator(), new $fullEntityClass) ); return new $fullRepoClass($tableGateway); }, ]); };
That’s it!
Apply Twig Extension via Middleware in Slim 4
If we use Slim 4, we can use Twig for template engine, and there is slim/twig-view
for that. However, when dealing with extension, especially routing and uri related stuff, we can’t use a way like in Slim 3, at least, by default, at least, what I know right now. There is an alternative way to add twig extension, which is via Middleware!
First, I assume that the Twig
service already registered in app/dependencies.php
:
<?php // app/dependencies.php declare(strict_types=1); use DI\ContainerBuilder; use Psr\Container\ContainerInterface; use Slim\Views\Twig; return function (ContainerBuilder $containerBuilder) { $containerBuilder->addDefinitions([ // ... Twig::class => function (ContainerInterface $c) { return new Twig(__DIR__ . '/../templates', [ 'cache' => __DIR__ . '/../var/cache', 'auto_reload' => true ]); }, // ... ]); };
Now, we need to add extension, which consume Slim\Routing\RouteCollector
and Slim\Psr7\Uri
instance. Now, we can create middleware on the fly in app/middleware.php
, eg: via anonymous class that implements Psr\Http\Server\MiddlewareInterface
.
<?php // app/middleware.php declare(strict_types=1); // ... use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Slim\App; use Slim\Routing\RouteCollector; use Slim\Views\Twig; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; // ... return function (App $app) { // ... $container = $app->getContainer(); $twig = $container->get(Twig::class); $router = $app->getRouteCollector(); $app->add(new class ($twig, $router) implements MiddlewareInterface { private $twig; private $router; public function __construct(Twig $twig, RouteCollector $router) { $this->twig = $twig; $this->router = $router; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $uri = $request->getUri(); $this->twig->addExtension(new class ($this->router, $uri) extends AbstractExtension { private $router; private $uri; public function __construct(RouteCollector $router, UriInterface $uri) { $this->router = $router; $this->uri = $uri; } public function getFunctions() { return [ new TwigFunction('base_path', function () : string { return $this->router->getBasePath(); }), new TwigFunction('full_url_for', function (string $routeName, array $data = [], array $queryParams = []) : string { return $this->router->getRouteParser()->fullUrlFor($this->uri, $routeName, $data, $queryParams); }), ]; } }); return $handler->handle($request); } }); // ... };
In its __construct
, we inject with Slim\Views\Twig
and Slim\Routing\RouteCollector
instance. In its process()
function, we call Slim\Views\Twig::addExtension()
function to add extension, which we can create another anonymous class that extends Twig\Extension\AbstractExtension
, injected with Slim\Routing\RouteCollector
and Slim\Psr7\Uri
(which pulled from object instance of Psr\Http\Message\ServerRequestInterface
), which we can add getFunctions()
method to add twig functions.
That’s it, in above example, I added base_path
and full_url_for
function which now can be used in twig view, eg:
{# display base path url #} {{ base_path() }} {# display full url for route with name "postpagedetail" with parameter id = 1 and query parameter action = view #} {{ full_url_for('postpagedetail', { 'id': 1 }, { 'action': 'view' }) }}
You are feeling have too much code in
app/middleware.php
? Of course, you can create a separate class for it!
Using DDD architecture with zend-db in Slim 4
Slim 4 provides a skeleton that uses DDD directory style. By this, we can implement DDD architecture easier. Let’s try with zend-db
for it. In this post, I assume that you already uses Twig
as view template engine like in previous post.
Requirements
First, we need to require zend-db via composer:
$ composer require zendframework/zend-db \ zendframework/zend-hydrator \ --sort-packages
We are going to make pages to display post
table record(s) with the following data:
# create and use database create database slim; use slim; # create table create table post(id int not null primary key auto_increment, title varchar(50) not null, content text not null); # insert data insert into post(title, content) values('first post', 'first post content'); insert into post(title, content) values('second post', 'second post content');
Database Configuration
<?php // app/settings.php // ... return function (ContainerBuilder $containerBuilder) { $containerBuilder->addDefinitions([ // ... 'db' => [ 'username' => 'root', 'password' => '', 'driver' => 'pdo_mysql', 'database' => 'slim', 'host' => 'localhost', 'driver_options' => [ \PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES \'UTF8\'', ], ], // ... ], ]); };
We can register database configuration at app/settings.php
, for example, use MySQL database as configured above.
DB Service
We are using service named Zend\Db\Adapter\AdapterInterface
, so we can register as follow in app/dependencies.php
:
<?php // app/dependencies.php declare(strict_types=1); use DI\ContainerBuilder; use Psr\Container\ContainerInterface; use Zend\Db\Adapter\Adapter; use Zend\Db\Adapter\AdapterInterface; return function (ContainerBuilder $containerBuilder) { $containerBuilder->addDefinitions([ // ... AdapterInterface::class => function (ContainerInterface $c) { return new Adapter($c->get('settings')['db']); }, // ... ]); };
Domain
a. Post
class:
<?php // src/Domain/Post/Post.php declare(strict_types=1); namespace App\Domain\Post; final class Post { public $id; public $title; public $content; public const TABLE = 'post'; public function __construct(Post $post = null) { if ($post instanceof Post) { $this->id = +$post->id; $this->title = ucwords($post->title); $this->content = ucfirst($post->content); } } }
b. PostRepository
interface:
<?php // src/Domain/Post/PostRepository.php declare(strict_types=1); namespace App\Domain\Post; interface PostRepository { public function findAll(): array; public function findPostOfId(int $id): Post; }
c. PostNotFoundException
class:
<?php // src/Domain/Post/PostNotFoundException.php declare(strict_types=1); namespace App\Domain\Post; use App\Domain\DomainException\DomainRecordNotFoundException; class PostNotFoundException extends DomainRecordNotFoundException { public $message = 'The post you requested does not exist.'; }
Infrastructure
We can create a repository that implements App\Domain\Post\PostRepository
, eg: named ZendDbPostRepository
:
<?php // src/Infrastructure/Persitence/Post/ZendDbPostRepository.php declare(strict_types=1); namespace App\Infrastructure\Persistence\Post; use function compact; use App\Domain\Post\Post; use App\Domain\Post\PostNotFoundException; use App\Domain\Post\PostRepository; use Zend\Db\TableGateway\AbstractTableGateway; class ZendDbPostRepository implements PostRepository { private $tableGateway; public function __construct(AbstractTableGateway $tableGateway) { $this->tableGateway = $tableGateway; } public function findAll(): array { $results = $this->tableGateway->select(); $posts = []; foreach ($results as $result) { $posts[] = new Post($result); } return $posts; } public function findPostOfId(int $id): Post { $current = $this->tableGateway->select(compact('id'))->current(); if (! $current) { throw new PostNotFoundException(); } return new Post($current); } }
The Repository class above needs to be registered to app/repositories.php
:
<?php // app/repositories.php declare(strict_types=1); use App\Domain\Post\Post; use App\Domain\Post\PostRepository; use App\Infrastructure\Persistence\Post\ZendDbPostRepository; use DI\ContainerBuilder; use Psr\Container\ContainerInterface; use Zend\Db\Adapter\AdapterInterface; use Zend\Db\ResultSet\HydratingResultSet; use Zend\Db\TableGateway\TableGateway; use Zend\Hydrator\ObjectPropertyHydrator; return function (ContainerBuilder $containerBuilder) { $containerBuilder->addDefinitions([ // ... PostRepository::class => function (ContainerInterface $c) { $tableGateway = new TableGateway( Post::TABLE, $c->get(AdapterInterface::class), null, new HydratingResultSet(new ObjectPropertyHydrator(), new Post()) ); return new ZendDbPostRepository($tableGateway); }, // ... ]); };
Application
For application, we can create 2 classes to display all posts and display post by id.
a. All posts via PostPage
class
<?php // src/Application/Page/PostPage.php declare(strict_types=1); namespace App\Application\Page; use App\Domain\Post\PostRepository; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Slim\Psr7\Response; use Slim\Views\Twig; class PostPage implements RequestHandlerInterface { private $view; private $postRepository; public function __construct(Twig $view, PostRepository $postRepository) { $this->view = $view; $this->postRepository = $postRepository; } public function handle(ServerRequestInterface $request) : ResponseInterface { return $this->view->render( new Response(), 'page/post/index.html.twig', [ 'posts' => $this->postRepository->findAll(), ] ); } }
b. Post By Id via PostPageDetail
class
<?php // src/Application/Page/PostPageDetail.php declare(strict_types=1); namespace App\Application\Page; use App\Domain\DomainException\DomainRecordNotFoundException; use App\Domain\Post\PostRepository; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Slim\Exception\HttpNotFoundException; use Slim\Psr7\Response; use Slim\Views\Twig; class PostPageDetail implements RequestHandlerInterface { private $view; private $postRepository; public function __construct(Twig $view, PostRepository $postRepository) { $this->view = $view; $this->postRepository = $postRepository; } public function handle(ServerRequestInterface $request) : ResponseInterface { $id = +$request->getAttribute('id'); try { $post = $this->postRepository->findPostOfId($id); } catch (DomainRecordNotFoundException $e) { throw new HttpNotFoundException($request, $e->getMessage()); } return $this->view->render( new Response(), 'page/post/detail.html.twig', [ 'post' => $post, ] ); } }
Routing
Now, time to register routing:
<?php // app/routes.php declare(strict_types=1); use App\Application\Page\PostPage; use App\Application\Page\PostPageDetail; use Slim\App; use Slim\Handlers\Strategies\RequestHandler; return function (App $app) { $routeCollector = $app->getRouteCollector(); $routeCollector->setDefaultInvocationStrategy( new RequestHandler(true) ); $app->get('/posts', PostPage::class); $app->get('/posts/{id:[0-9]+}', PostPageDetail::class); };
View
a. templates/page/post/index.html.twig
:
{# templates/page/post/index.html.twig #} {% extends "layout.html.twig" %} {% block body %} {% for post in posts %} <h3> {{ post.title }} </h3> <p> {{ post.content }} </p> {% endfor %} {% endblock %}
b. templates/page/post/detail.html.twig
:
{# templates/page/post/detail.html.twig #} {% extends "layout.html.twig" %} {% block body %} <h3> {{ post.title }} </h3> <p> {{ post.content }} </p> {% endblock %}
Run PHP Server
php -S localhost:8080 -t public
Now, we can see the pages, http://localhost:8080/posts for all posts:
and http://localhost:8080/posts/1 for detail post:
Using routed PSR-15 RequestHandlerInterface in Slim 4
Slim 4 already support PSR-15, and we can use class implements Psr\Http\Server\RequestHandlerInterface
that produce response and register to the routes. First, we can install Slim-Skeleton
to get started:
$ composer create-project slim/slim-skeleton
After it, for example, we have the following AboutPage
class:
<?php // src/Application/Page/AboutPage.php declare(strict_types=1); namespace App\Application\Page; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Slim\Psr7\Response; class AboutPage implements RequestHandlerInterface { public function handle(ServerRequestInterface $request) : ResponseInterface { $response = new Response(); $response->getBody()->write('about page'); return $response; } }
We can register above to routes configuration in app/routes.php
as follow:
<?php // app/routes.php declare(strict_types=1); use App\Application\Page\AboutPage; use Slim\App; return function (App $app) { // ... $app->get('/about', AboutPage::class); };
To test, we can run PHP Development Server:
$ php -S localhost:8080 -t public
Then open it in the web browser as http://localhost:8080/about :
How about injecting service(s) to it?
We can supply the service(s) into its __construct
and it will automatically injected with the service(s), eg: we need to inject service named Psr\Log\LoggerInterface
which its service definition already registered in app/dependencies.php
:
<?php // src/Application/Page/AboutPage.php // ... use Psr\Log\LoggerInterface; // ... class AboutPage implements RequestHandlerInterface { private $logger; public function __construct(LoggerInterface $logger) { $this->logger = $logger; } // ... }
How about add template?
For example, we want to use slim/twig-view
package for it, we can first require it:
$ composer require slim/twig-view
After it, we can create cache
and templates
directories for it:
# create var/cache directory $ mkdir -p var/cache # ignore cached data from git repo (in case use git as version control) $ touch var/cache/.gitignore && (echo '*' && echo '!.gitignore') > var/cache/.gitignore # create templates directories $ mkdir -p templates/page
By above directory structure, we can register Slim\Views\Twig
service in app/dependencies.php
as follow:
<?php // app/dependencies.php // ... use Slim\Views\Twig; return function (ContainerBuilder $containerBuilder) { $containerBuilder->addDefinitions([ // ... Twig::class => function (ContainerInterface $c) { return new Twig(__DIR__ . '/../templates', [ 'cache' => __DIR__ . '/../var/cache', 'auto_reload' => true ]); }, // ... ]); };
After it, we can prepare the templates with the following structure:
├───templates │ │ layout.html.twig │ └───page │ about.html.twig
Next, apply templates/layout.html.twig
:
<!-- templates/layout.html.twig --> <!DOCTYPE html> <html> <head> <title>{% block title %}Slim App{% endblock %}</title> </head> <body> {% block body %}{% endblock %} </body> </html>
Next, apply templates/page/about.html.twig
:
<!-- templates/page/about.html.twig --> {% extends "layout.html.twig" %} {% block body %} About Page. {% endblock %}
Finally, in the AboutPage
class, we can inject Slim\Views\Twig
instance and use it to render the twig view as instance of Slim\Psr7\Response
:
<?php // src/Application/Page/AboutPage.php declare(strict_types=1); namespace App\Application\Page; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Slim\Psr7\Response; use Slim\Views\Twig; class AboutPage implements RequestHandlerInterface { private $view; public function __construct(Twig $view) { $this->view = $view; } public function handle(ServerRequestInterface $request) : ResponseInterface { return $this->view->render(new Response(), 'page/about.html.twig'); } }
How about getting request attribute value?
To do this, we need to enable it in the routing by apply Slim\Handlers\Strategies\RequestHandler
as its router invocation strategy, so, for example, we need to pass optional “id” parameter, we can do in app/routes
:
<?php // app/routes.php declare(strict_types=1); use App\Application\Page\AboutPage; use Slim\App; use Slim\Handlers\Strategies\RequestHandler; return function (App $app) { // ... $app->get('/about[/{id:[0-9]+}]', AboutPage::class) ->setInvocationStrategy(new RequestHandler(true)); };
We can also set default invocation strategy by apply it before register the route(s) that the pages implements RequestHandlerInterface
:
<?php // app/routes.php declare(strict_types=1); use App\Application\Page\AboutPage; use Slim\App; use Slim\Handlers\Strategies\RequestHandler; return function (App $app) { $routeCollector = $app->getRouteCollector(); // ... before setDefaultInvocationStrategy placement, // ... it will compatible with RequestResponse strategy $routeCollector->setDefaultInvocationStrategy(new RequestHandler(true)); // ... after setDefaultInvocationStrategy placement, // ... it will compatible only with selected default invocable strategy $app->get('/about[/{id:[0-9]+}]', AboutPage::class); };
So, from now on, we can get attribute in the handle()
method and passed parameter to the view:
<?php // src/Application/Page/AboutPage.php declare(strict_types=1); namespace App\Application\Page; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Slim\Psr7\Response; use Slim\Views\Twig; use Psr\Log\LoggerInterface; class AboutPage implements RequestHandlerInterface { private $view; public function __construct(Twig $view) { $this->view = $view; } public function handle(ServerRequestInterface $request) : ResponseInterface { $id = $request->getAttribute('id'); return $this->view->render( new Response(), 'page/about.html.twig', [ 'id' => $id ] ); } }
So, in the view, we can do:
<!-- templates/page/about.html.twig --> {% extends "layout.html.twig" %} {% block body %} About Page with id {{ id }} {% endblock %}
and when open http://localhost:8080/about/1 , we can get:
That’s it!
leave a comment