How can I set custom 404 page handling?

In some cases, you may want to handle 404 errors separately from the final handler. This can be done by registering middleware that operates late — specifically, after the routing middleware. Such middleware will be executed if no other middleware has executed, and/or when all other middleware calls return $next() without returning a response. Such situations typically mean that no middleware was able to complete the request.

Your 404 handler can take one of two approaches:

  • It can set the response status and call $next() with an error condition. In such a case, the final handler will likely be executed, but will have an explicit 404 status to work with.
  • It can create and return a 404 response itself.

Calling next with an error condition

In the first approach, the NotFound middleware can be as simple as this:

namespace Application;

class NotFound
{
    public function __invoke($req, $res, $next)
    {
        // Other things can be done here; e.g., logging
        return $next($req, $res->withStatus(404), 'Page Not Found');
    }
}

This example uses the third, optional argument to $next(), which is an error condition. Internally, the final handler will typically see this, and return an error page of some sort. Since we set the response status, and it's an error status code, that status code will be used in the generated response.

The TemplatedErrorHandler will use the error template in this particular case, so you will likely need to make some accommodations for 404 responses in that template if you choose this approach.

404 Middleware

In the second approach, the NotFound middleware will return a full response. In our example here, we will render a specific template, and use this to seed and return a response.

namespace Application;

use Zend\Expressive\Template\TemplateRendererInterface;

class NotFound
{
    private $renderer;

    public function __construct(TemplateRendererInterface $renderer)
    {
        $this->renderer = $renderer;
    }

    public function __invoke($req, $res, $next)
    {
        // other things can be done here; e.g., logging
        // Now set the response status and write to the body
        $response = $res->withStatus(404);
        $response->getBody()->write($this->renderer->render('error::not-found'));
        return $response;
    }
}

This approach allows you to have an application-specific workflow for 404 errors that does not rely on the final handler.

Registering custom 404 handlers

We can register either Application\NotFound class above as service in the service container. In the case of the second approach, you would also need to provide a factory for creating the middleware (to ensure you inject the template renderer).

From there, you still need to register the middleware. This middleware is not routed, and thus needs to be piped to the application instance. You can do this via either configuration, or manually.

To do this via configuration, add an entry under the middleware_pipeline configuration, after the dispatch middleware:

'middleware_pipeline' => [
    /* ... */
    'routing' => [
        'middleware' => [
            Zend\Expressive\Container\ApplicationFactory::ROUTING_MIDDLEWARE,
            Zend\Expressive\Helper\UrlHelperMiddleware::class,
            Zend\Expressive\Container\ApplicationFactory::DISPATCH_MIDDLEWARE,
        ],
        'priority' => 1,
    ],
    [
        'middleware' => 'Application\NotFound',
        'priority' => -1,
    ],
    /* ... */
],

The above example assumes you are using the ApplicationFactory and/or the Expressive skeleton to manage your application instantiation and configuration.

To manually add the middleware, you will need to pipe it to the application instance:

$app->pipe($container->get('Application\NotFound'));

This must be done after:

  • calling $app->pipeDispatchMiddleware(), OR
  • pulling the Application instance from the service container (assuming you used the ApplicationFactory).

This is to ensure that the NotFound middleware executes after any routed middleware, as you only want it to execute if no routed middleware was selected.