How can I create a Maintenance Page in Symfony?


In this video we are going to cover how to add in a Maintenance page to our Symfony application.

The idea here is a simple one:

When you are undertaking work on your live site, this page would be displayed to the public along with a 503 / Service Unavailable error code.

However, it may be that you do not need a Maintenance page at all.

If you use a deployment tool such as Deployer then your site changes should be effectively seamless from the point of view of your users.

It could be argued that if your site is in maintenance mode then there is a strong likelihood that your Symfony app may either be unavailable at worst, or in some problematic state whereby it couldn't handle incoming requests anyway.

In this instance it makes sense, in my opinion, to push the maintenance notification further up the chain (so to speak), letting your web server handle this circumstance instead.

Road Works Ahead

There are times when periods of maintenance are unavoidable. Even the biggest of websites infrequently go offline for a short while. Heck, even Symfony.com sometimes shows the maintenance page.

And whilst this feature request is fairly common, it doesn't look like it will ever officially be directly addressed.

Whilst that's a shame, it is also understandable.

I take the opinion that all of this depends on the scale of your application.

Not every Symfony installation is running as an enterprise system with teams of staff behind it. If you run small scale / side projects with Symfony then the following approach may very well be good enough.

Besides which, even if you review this approach and think - Chris, that is crazy - then you very well may still learn a little something about event listeners along the way.

Listening For Incoming Requests

The way in which we are going to implement our maintenance page is by listening for incoming requests, and then should a certain condition exist, we will return a 503 response with our maintenance page content.

As mentioned above, this approach has a few disadvantages:

  1. It requires your Symfony site to be up and able to handle incoming requests
  2. It will involve checking every single request, even for the 99.9% of time you are not in maintenance
  3. It may involve a cache:clear to enable / disable

Likely you can think of a few others.

Before you decide if you are happy to proceed, let's address each of these points in turn.

It requires your Symfony site to be up and able to handle incoming requests

We are going to add an event listener to our Symfony site that will listen for any incoming requests.

If we need to display the maintenance site because our latest code change broke the site in a fundamental way, well... that's not going to work.

If we were to add a rule to our Apache or nginx configuration instead, we would never need to hit the Symfony site to worry about this in the first place.

It will involve checking every single request, even for the 99.9% of time you are not in maintenance

Ok, we got passed the first point, but now we are checking each and every single request to figure out whether we should be returning a 503 error or not.

It's not quite as bad as it sounds - so long as you don't go crazy with your logic.

One way of doing this may be a simple file_exists check, another might be to pass in a boolean value from your parameters.yml. But if you take that second approach...

It will involve a cache:clear to enable / disable

Personally I would prefer to go with a way of turning maintenance mode off and on that doesn't involve updating config.

The reason I say this is that should you have your code under version control (and being 2016, and professionals, you better!), then you're going to end up with a patch level release just to switch out, and another to switch back... It's crazy.

Imagine - 3.2.0, push to live.

Realise there is a problem, 3.2.1, enable maintenance mode, push to live.

Was that really a patch? Is it any different to 3.2.0?

Honestly you may think this is all just crazy talk, but I've seen big, big companies do this sort of thing. It leads in one direction - messy headaches.

Listener Service Definition

Have I not put you off yet? I've tried, really hard. If you can take 745 words of warning that perhaps, this isn't the best approach then I guess there's no stopping you.

Let's define our service definition so we can tell Symfony about our event listener.

# /app/config/services.yml

services:

    maintenance_listener:
        class: AppBundle\Event\MaintenanceListener
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

Ok, so a few things going on here.

Even if you aren't going to implement a maintenance mode listener, this is interesting all the same.

Firstly, we have defined our simple service. We've called it maintenance_listener.

We've told Symfony that this maintenance_listener will use the class of AppBundle\Event\MaintenanceListener.

That's the easy part out of the way.

Next, we tag the service.

If I am being honest, tags confused the heck out of me for a good long while. I came to realise I was drastically over thinking them.

Simply, a tag is akin to a WordPress tag. You write out a post, then you tag the post with relevant terms. Many posts can share similar tags, and site users can even browse your tags to find other related content.

Similarly, tagging gives Symfony the purpose in which this particular service is supposed to be used.

Right out of the box, Symfony comes with a whole slew of tags (more correctly called Dependency Injection Tags) and the one we are going to use is kernel.event_listener. With this tag we are telling Symfony that this service is interested in listening to the various events that Symfony dispatches throughout your application's lifecycle.

The thing is, Symfony dispatches a bunch of events during any particular journey from request to response. You may even be dispatching some of your own.

It's no use listening to every single event.

We need to be more specific.

That's where the event key comes into play.

Here we explicitly define our intent. We are only interested in kernel.request events.

Lastly, when this event is 'heard', what should we do?

We want to call a method in our class, and whilst that method could be called anything, a good convention is to start with on, and then follow it with the event name you are listening for, e.g. onKernelRequest.

Given all this, we can go ahead and implement our AppBundle\Event\MaintenanceListener class.

Listener Implementation

Let's start by defining the outline of our class:

<?php

// /src/AppBundle/Event/MaintenanceListener.php

use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class MaintenanceListener
{
    public function onKernelRequest(GetResponseEvent $event)
    {
        // our logic will go here
    }
}

Our application should still load and function without any issue at this stage, yet our listener will be being invoked for every request. It just happens that the listener won't do anything, other than waste some CPU cycles.

Adding the logic here is pretty straightforward. If you have a good IDE (such as PHPStorm) you will get helpful autocomplete on the $event which will show you pretty much everything your can do.

We will force our site into maintenance mode now by creating a 503 response and stopping any other requests from being processed:

<?php

// /src/AppBundle/Event/MaintenanceListener.php

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class MaintenanceListener
{
    public function onKernelRequest(GetResponseEvent $event)
    {
        $event->setResponse(
          new Response(
            'site is in maintenance mode',
            Response::HTTP_SERVICE_UNAVAILABLE
          )
        );
        $event->stopPropagation();
    }
}

I've spread the content out over multiple lines to make it easier to read, but a one-liner is fine also.

I'm using one of the constants on the Symfony's Response class as I personally find them easier to understand a glance, over say a 503 code which 99 times out of 100, I would need to use a quick Google for.

This looks a bit rubbish, but it does the job.

If you try and load your site now then you should see the plain text error of 'site is in maintenance mode', and if you check the request in your developer tools you should be able to see the 503 status code.

This is as simple as it gets. Our site is now down.

The Mind Toggles

Likely you want a way to turn this thing off and on.

One simple way would be to check for the existance of a file:

<?php

// /src/AppBundle/Event/MaintenanceListener.php

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class MaintenanceListener
{
    public function onKernelRequest(GetResponseEvent $event)
    {
        if ( ! file_exists('/path/to/your/web/dir/.some_file')) {
            return;
        }

        $event->setResponse(
          new Response(
            'site is in maintenance mode',
            Response::HTTP_SERVICE_UNAVAILABLE
          )
        );
        $event->stopPropagation();
    }
}

Here we do a simple check for the existance of a file at a given path, and if it doesn't exist then we aren't in maintenance mode.

Simple, but it works.

We can improve on this. Rather than hardcode the path, we could inject the lock file path as a parameter:

<?php

// /src/AppBundle/Event/MaintenanceListener.php

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class MaintenanceListener
{
    private $lockFilePath;

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

    public function onKernelRequest(GetResponseEvent $event)
    {
        if ( ! file_exists($this->lockFilePath)) {
            return;
        }

        $event->setResponse(
          new Response(
            'site is in maintenance mode',
            Response::HTTP_SERVICE_UNAVAILABLE
          )
        );
        $event->stopPropagation();
    }
}

We would now need to create the parameter, likely in parameters.yml (also remembering to update parameters.yml.dist):

# /app/config/parameters.yml

parameters:
    # etc

    lockFilePath: "%kernel.root_dir%/../web/.lock"

If you are unsure here, %kernel.root_dir% is a special parameter built into Symfony that resolves to the location of your app directory.

Here we say find that app directory, then go up and out of it /../ and then go into the web directory and find the .lock file.

Of course you can change this path to be whatever you like. Likewise, the .lock file name is just something I have created, feel free to name it whatever you like.

We also need to update the service definition for our maintenance_listener, telling it to pass in this new parameter as a constructor argument to our service:

# /app/config/services.yml

services:

    maintenance_listener:
        class: AppBundle\Event\MaintenanceListener
        arguments:
            - "%lockFilePath%"
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

The reason I use a path instead of a boolean such as :

# /app/config/parameters.yml

parameters:
    # etc

    isLocked: true

and

<?php

// /src/AppBundle/Event/MaintenanceListener.php

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class MaintenanceListener
{
    private $isLocked;

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

    public function onKernelRequest(GetResponseEvent $event)
    {
        if ( ! $this->isLocked) {
            return;
        }

        // etc

Is that if we do use a boolean, then we change the parameter to false, well, we would need to clear our cache (bin/console cache:clear -e=prod).

By simply looking for a file path, we can touch and rm the file without needing to change our Symfony code at all. It's a minor improvement sir, but it checks out.

Making It Look Fancy

Maybe you don't just want a bog standard plain text page.

Maybe you want a nice Twigged up fancy pants maintenance page.

That's fine too, you can inject something to render out a nicer looking page:

<?php

// /src/AppBundle/Event/MaintenanceListener.php

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;

class MaintenanceListener
{
    private $isLocked;
    private $twig;

    public function __construct($isLocked, \Twig_Environment $twig)
    {
        $this->isLocked = $isLocked;
        $this->twig = $twig;
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
        if ( ! $this->isLocked) {
            return;
        }

        $page = $this->twig->render('::maintenance.html.twig');

        $event->setResponse(
          new Response(
            $page,
            Response::HTTP_SERVICE_UNAVAILABLE
          )
        );
        $event->stopPropagation();

You would also need to inject Twig:

# /app/config/services.yml

services:

    maintenance_listener:
        class: AppBundle\Event\MaintenanceListener
        arguments:
            - "%lockFilePath%"
            - "@twig"
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

And so long as you have a twig template, in this instance, at app/Resources/views/maintenance.html.twig, then when you are in maintenance mode you should now see a fancy HTML page instead.

Wait, There's Bundles For This?

I was a bit shocked to find this out myself, but this concept has been done by at least two bundles.

There's LexikMaintenanceBundle and CorleyMaintenanceBundle, should you not wish to implement this feature yourself.

In my opinion, this is already a bit of push in terms of whether Symfony should be handling this for you. To think you would need a third party bundle / another dependancy to manage this testing even my limits of pulling in other peoples code - and I do love me a good bundle.

That said, these third party bundles do the job, and they do more than you would likely implement yourself. If you really want or need these extra features then feel free to investigate each bundle further.

I'd love to heard your thoughts on this one, whether you agree or not. Please do leave a comment and let me know. Alternative solutions are always very welcome.

Episodes