Un mode maintenance sur Symfony 6

Un mode maintenance sur Symfony 6

Mettre en place un mode maintenance sur son application Symfony

27 avril 2022

Un mode maintenance sur Symfony 6

Sur un site ecommerce avec une partie administrateur, je souhaite mettre en place un mode maintenance qui redirige tous les utilisateurs sur une page dédiée, mais qui permet aux admins de pouvoir tout de même se connecter.

J'ai d'abord parcouru tous les bundles existants, mais j'ai finalement décidé d'opter pour ma propre approche.

Dans mon cas, la partie administrateur du site n'a pas été réalisée à l'aide d'un bundle (EasyAdminBundle ou SonataAdmin) mais cela ne devrait pas poser problème si vous avez fait le choix d'utiliser l'un d'eux dans votre application.

Vue d'ensemble

  • Entity

    Entité très simple qui va permettre de stocker la valeur de la maintenance dans la base de données.

  • Controller

    Mise en place du simple controller permettant de définir la route ainsi que de renvoyer la view concernant la page de maintenance.

  • View

    On crée la vue retournée pour la page de maintenance.

  • Event Subscriber

    Écoute l'évènement kernel.request et vérifie le statut de la maintenance.

Assurez-vous d'avoir le maker-bundle pour faciliter les opérations.

composer require --dev symfony/maker-bundle

Les différentes parties

Entity

Personnellement, j'ai choisi d'appeler cette entité GlobalOption.
Ce n'est qu'à titre d'exemple, car je me dis que je pourrais aussi stocker d'autres informations globales concernant certaines fonctionnalités de mon application.
Par exemple : Autoriser les commentaires sur les produits ?

On commence par créer l'entité qui va simplement être composée d'une clé et d'une valeur.
C'est-à-dire 2 champs, un champ name et un champ value.

  • name sera de type string
  • value sera de type boolean (traduit en tinyint pour la base MySQL)
php bin/console make:entity GlobalOption

On n'oublie pas de générer la migration.

php bin/console make:migration

Ainsi que de l'exécuter pour mettre à jour notre base de données.

php bin/console d:m:m

On se retrouve avec le fichier GlobalOption.php que le maker-bundle a généré pour nous.

Voir le contenu
src/Entity/GlobalOption.php
namespace App\Entity;

use App\Repository\GlobalOptionRepository;
use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Entity(repositoryClass=GlobalOptionRepository::class)
*/
class GlobalOption
{
  /**
   * @ORM\Id
   * @ORM\GeneratedValue
   * @ORM\Column(type="integer")
   */
  private $id;

  /**
   * @ORM\Column(type="string", length=255)
   */
  private $name;

  /**
   * @ORM\Column(type="boolean", nullable=true)
   */
  private $value;

  public function getId(): ?int
  {
      return $this->id;
  }

  public function getName(): ?string
  {
      return $this->name;
  }

  public function setName(string $name): self
  {
      $this->name = $name;

      return $this;
  }

  public function getValue(): ?bool
  {
      return $this->value;
  }

  public function setValue(?bool $value): self
  {
      $this->value = $value;

      return $this;
  }
}

Controller

Grâce au maker bundle, on va pouvoir créer le controller rapidement.
Je l'ai appelé MaintenanceController mais adoptez le nom qui vous plaît !

php bin/console make:controller MaintenanceController

Dans ce controller, on crée la route qui correspondra à la page de maintenance.

src/Controller/MaintenanceController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class MaintenanceController extends AbstractController
{
    #[Route('/maintenance', name: 'maintenance')]
    public function maintenance(): Response
    {
        return $this->render('maintenance/index.html.twig');
    }
}
#[Route('/maintenance', name: 'maintenance')]

J'utilise ici les attributs de PHP 8 pour définir mes annotations.

Donc en pratique, lorsque le mode maintenance sera activé, l'utilisateur sera redirigé vers la route portant le nom maintenance, vers l'URL $HOST/maintenance.

View

Je ne vais pas trop rentrer dans les détails pour la vue, vous faites comme bon vous semble...
Ici, simplement expliquer à l'utilisateur que le site est momentanément indisponible !

{% extends "base.html.twig" %}

{% block title %}Site en maintenance{% endblock %}

{% block body %}
<div class="bg-beige rounded-lg">
    <div class="py-6">
        <img class="mx-auto" src="{{ asset('images/brand/logo.svg') }}" width="300" alt="">
        <div class="mx-auto max-w-prose">
            <p>Le site est actuellement en maintenance. Cela ne devrait pas durer trop longtemps !</p>
            <p>Veuillez nous excuser pour la gêne occasionnée... Merci.</p>
        </div>
    </div>
</div>
{% endblock %}

Event Subscriber

L'Event Subscriber, n'est rien d'autre que, par définition, une classe qui contient une ou plusieurs méthodes qui vont écouter un ou plusieurs évènements.

Je commence donc par créer un dossier EventListener dans le dossier src de mon application.
Puis je crée ma classe MaintenanceStatusSubscriber à l'intérieur.

Ensuite, je réfléchis à ce dont j'ai besoin...

  • J'ai besoin d'accéder au repository de mon entité (en l'occurrence, GlobalOptionRepository) pour savoir si la maintenance est activée ou non

  • J'aimerais pouvoir savoir si l'utilisateur est connecté ou non

  • J'aimerais pouvoir savoir si l'utilisateur à le ROLE_ADMIN ou non

  • J'aimerais pouvoir émettre une RedirectResponse vers ma route maintenance à un moment donné

Parfait ! Je commence à écrire dans mon fichier MaintenanceStatusSubscriber, et passe dans mon constructeur les dépendances nécessaires aux 4 besoins que j'ai cités juste au-dessus. J'en profite également pour créer les fonctions onKernelRequest() et getSubscribedEvents() que je remplirai après.

src/EventListener/MaintenanceStatusSubscriber.php
namespace App\EventListener;
class MaintenanceStatusSubscriber implements EventSubscriberInterface
{
    protected AuthorizationCheckerInterface $authorizationChecker;
    protected GlobalOptionRepository $globalOptionRepository;
    protected RouterInterface $router;

    public function __construct(
        AuthorizationCheckerInterface $authorizationChecker,
        GlobalOptionRepository $globalOptionRepository,
        RouterInterface $router)
    {
        $this->authorizationChecker = $authorizationChecker;
        $this->globalOptionRepository = $globalOptionRepository;
        $this->router = $router;
    }

    public function onKernelRequest()
    {
        // [...]
    }

    public static function getSubscribedEvents()
    {
        // [...]
    }

}

Dans la fonction getSubscribedEvents() je vais retourner pour chaque requête la fonction onKernelRequest().

src/EventListener/MaintenanceStatusSubscriber.php
namespace App\EventListener;
class MaintenanceStatusSubscriber implements EventSubscriberInterface
{
    public function __construct()
    {
        // [OK]
    }

    public function onKernelRequest()
    {
        // [...]
    }

    public static function getSubscribedEvents()
    {
        return [
            RequestEvent::class => 'onKernelRequest'
        ];
    }

}

Toute ma logique va s'appliquer dans la fonction onKernelRequest().
Je n'oublie pas ne passer l'évènement RequestEvent en paramètre.

Je souhaite permettre aux administrateurs authentifiés de pouvoir accéder à l'ensemble du site.
Aussi, je définis dans un tableau les routes autorisées par leur nom.
Cela va me permettre de dire par exemple que la route contact sera accessible en mode maintenance même pour les visiteurs !
Il est important d'ajouter dans le tableau la route maintenance elle-même pour éviter une erreur de type TOO_MANY_REDIRECTS.
Également la route qui permet de s'authentifier sur mon application. Dans mon cas elle s'appelle security_login.
src/EventListener/MaintenanceStatusSubscriber.php
namespace App\EventListener;
class MaintenanceStatusSubscriber implements EventSubscriberInterface
{
    public function __construct()
    {
        // [OK]
    }

    public function onKernelRequest(RequestEvent $event)
    {
        // Obtenir la route actuelle
        $currentRoute = $event->getRequest()->get('_route');
        
        // Vérifier le statut de la maintenance
        $maintenanceStatus = $this->globalOptionRepository->findOneBy(['name' => 'maintenance'])->getValue();
        
        // Définir la redirection vers la page de maintenance
        $maintenanceResponse = new RedirectResponse($this->router->generate('maintenance'));
        
        // Routes autorisées durant le mode maintenance
        $allowedPublicMaintenanceRoutes = ['maintenance', 'contact', 'security_login'];
        
        // Si maintenance ON
        if ($maintenanceStatus === true && !in_array($currentRoute, $allowedPublicMaintenanceRoutes)) {

            // Vérifier si user est connecté ou non
            switch ($this->authorizationChecker->isGranted('IS_AUTHENTICATED_FULLY')) {

                // Si user connecté
                case true:

                    // Vérifier si user est admin ou non
                    switch ($this->authorizationChecker->isGranted('ROLE_ADMIN')) {

                        // Si user est admin
                        case true:
                            break;

                        // Si user n'est pas admin -> redirection
                        case false:
                            $event->setResponse($maintenanceResponse);
                    }
                    break;

                // Si user non connecté -> redirection
                case false:
                    $event->setResponse($maintenanceResponse);
            }

        }
    }

    public static function getSubscribedEvents()
    {
        // [OK]
    }

}
Voir le contenu
src/EventListener/MaintenanceStatusSubscriber.php
namespace App\EventListener;

use App\Repository\GlobalOptionRepository;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;

class MaintenanceStatusSubscriber implements EventSubscriberInterface
{
    protected AuthorizationCheckerInterface $authorizationChecker;
    protected GlobalOptionRepository $globalOptionRepository;
    protected RouterInterface $router;

    public function __construct(
        AuthorizationCheckerInterface $authorizationChecker,
        GlobalOptionRepository $globalOptionRepository,
        RouterInterface $router)
    {
        $this->authorizationChecker = $authorizationChecker;
        $this->globalOptionRepository = $globalOptionRepository;
        $this->router = $router;
    }

    public function onKernelRequest(RequestEvent $event)
    {
        // Obtenir la route actuelle
        $currentRoute = $event->getRequest()->get('_route');

        // Vérifier le statut de la maintenance
        $maintenanceStatus = $this->globalOptionRepository->findOneBy(['name' => 'maintenance'])->getValue();

        // Définir la redirection vers la page de maintenance
        $maintenanceResponse = new RedirectResponse($this->router->generate('maintenance'));

        // Routes autorisées durant le mode maintenance
        $allowedPublicMaintenanceRoutes = ['maintenance', 'contact', 'security_login'];

        // Si maintenance ON
        if ($maintenanceStatus === true && !in_array($currentRoute, $allowedPublicMaintenanceRoutes)) {

            // Vérifier si user est connecté ou non
            switch ($this->authorizationChecker->isGranted('IS_AUTHENTICATED_FULLY')) {

                // Si user connecté
                case true:

                    // Vérifier si user est admin ou non
                    switch ($this->authorizationChecker->isGranted('ROLE_ADMIN')) {

                        // Si user est admin
                        case true:
                            break;

                        // Si user n'est pas admin
                        case false:
                            $event->setResponse($maintenanceResponse);
                    }
                    break;

                // Si user non connecté
                case false:
                    $event->setResponse($maintenanceResponse);
            }

        }
    }

    public static function getSubscribedEvents()
    {
        return [
            RequestEvent::class => 'onKernelRequest'
        ];
    }

}

Conclusion

Super, ça fonctionne !

Je me suis amusé à mettre en place ce mode maintenance, qui peut facilement être amené à évoluer vers de plus amples fonctionnalités.

Il n'y avait pas de bundle, à ma connaissance, permettant de créer un mode maintenance tout en donnant l'accès à certaines parties de son application.

Mis à jour le : 27 avril 2022