Comment gérer les thèmes et la configuration multi site ?

Par @jbnahan69
https://www.pxfuel.com/en/free-photo-qzhvh

Dans l'introduction de cette série d'article, j'ai mentionné 3 grandes façon de gérer le code pour mutualiser la gestion de plusieurs sites (multi tenant). Dans la suite des articles, je vais développer une base de code permettant la gestion de plusieurs sites avec un seul projet (une seule base de code). Ce code sera installé autant de fois que de client (site web).

Quelques règles

Pour permettre de gérer au mieux le projet, des règles ont été établies.

Chaque installation dispose dans sa configuration en fonction de l'identifiant du client. Nous avons deux clients gérer dans la base de code: ClientA et ClientB.

Nous ne reviendrons pas sur la raison qui nous pousse à gérer tous les clients dans le même dépôt de code. Mais pour ces articles, le défi technique est la principale motivation.

Ce contexte particulier va mettre à l'épreuve la flexibilité de Symfony et de Doctrine.

Pour savoir quel est le client en cours, la variable d'environnement CLIENT_ID contiendra donc soit la valeur ClientA soit la valeur ClientB.

Après chaque changement de valeur de la variable d'environnement CLIENT_ID, il est nécessaire de vider le cache Symfony pour que la configuration du client soit chargée.

Par convention, le code dédié à un client est stocké dans un dossier à son nom dans src. La configuration et routes spécifiques au client est également stockée dans un dossier à son nom dans le dossier config.

Nous avons donc cette arborescence pour le dossier src :

src
├── ClientA
│   ├── Entity
│   └── Repository
├── ClientB
│   ├── Entity
│   └── Repository
└── Platform
    ├── Entity
    ├── Repository
    └── Service

Et pour config :

config
├── ClientA
│   ├── packages
│   └── routes
├── ClientB
│   ├── packages
│   └── routes
└── packages

Cet article suppose que vous connaissez bien Symfony et Doctrine. J'utilise également le client Symfony.

La gestion des templates

Je ne vais pas refaire la musique, j'en ai déjà parlé dans le projet hackSylius. Je vais utilisé le SyliusThemeBundle qui me permet d'avoir toute la souplesse nécessaire pour les templates.

J'ai du personnaliser le SyliusThemeBundle pour le rendre compatible avec Symfony 6.1.

Voici la classe écrite pour PHP 8.1 (elle est plus courte) :

final class ThemeContext implements ThemeContextInterface
{
    public function __construct(private readonly ThemeRepositoryInterface $themeRepository, private readonly string $clientId)
    {
    }
    /**
     * @inheritDoc
     */
    public function getTheme(): ?ThemeInterface
    {
        return $this->themeRepository->findOneByName('app/'.strtolower($this->clientId));
    }
}

La stratégie utilisé pour choisir le thème du client est simple, le nom du thème doit être la valeur de la variable CLIENT_ID en minuscule. Pour le ClientB, cela donne app/clientb.

Gestion de la configuration par client

Pour gérer la configuration et les routes par client, j'ai dû personnaliser le Kernel Symfony en modifiant les fonctions configureContainer et configureRoute.

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    private function getClientId(): ?string
    {
        return $_SERVER['CLIENT_ID'] ?? null;
    }

    private function configureContainer(ContainerConfigurator $container, LoaderInterface $loader, ContainerBuilder $builder): void
    {
        //...
        $clientId = $this->getClientId();
        if ($clientId && is_dir($configDir.'/'.$clientId.'/packages')) {
            $container->import($configDir.'/{'.$clientId.'}/packages/*.{php,yaml}');
            $container->import($configDir.'/{'.$clientId.'}/packages/'.$this->environment.'/*.{php,yaml}');
        }
        //...
    }

    private function configureRoutes(RoutingConfigurator $routes): void
    {
        $configDir = $this->getConfigDir();
        $clientId = $this->getClientId();
        //...
        if ($clientId && is_dir($configDir.'/'.$clientId.'/routes')) {
            $routes->import($configDir.'/{'.$clientId.'}/routes/'.$this->environment.'/*.{php,yaml}');
            $routes->import($configDir.'/{'.$clientId.'}/routes/*.{php,yaml}');
        }
        //...
    }
}

Ces fonctions récupère la valeur de la variable d'environnement dans la variable super globale $_SERVER dans laquelle les valeurs des fichiers .env sont chargées.

Dans le dossier "packages" de chaque client, je vais placer le fichier de configuration des services spécifique au client.

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\ClientB\:
        resource: '../../../src/ClientB/'
        exclude:
            - '../../../src/ClientB/Entity/'

Je place également le fichier de configuration des routes pour chaque client dans le dossier "routes" du client

controllers:
  resource: ../../../src/ClientB/Controller/
  type: attribute

La configuration Doctrine, doit également être modifiée. Le fichier "config/packages/doctrine.yaml" a déjà été modifier pour chercher les entité dans le dossier "src/Platform/Entity".

Je vais donc ajouter un fichier de configuration dans le dossiers"packages" de chaque client avec uniquement l'ajout d'un mapping pour le client.

doctrine:
    orm:
        mappings:
            AppClientB:
                is_bundle: false
                dir: '%kernel.project_dir%/src/ClientB/Entity'
                prefix: 'App\ClientB\Entity'
                alias: AppClientB

Voici le résultat obtenu (il faut supprimer le dossier de cache avant d'exécuter la commande)

Configuration Doctrine effective pour le client B.

Autres solutions de gestion de la configuration

Passe de compilation

Cette façon de faire évite l'ajout d'une ou plusieurs passe de compilation (CompilerPass) permettant de modifier les configurations et les services lors de la construction du conteneur de service (container).

L'inconvénient des passes de compilation est l’éparpillement de la configuration. En effet, les modifications de configurations serait soit dans des fichiers soit directement dans les passes de compilation.

L'option retenu utilise la fusion des configurations de Symfony. C'est le même principe que pour les configuration spécifique pour les environnements dev, test, prod de Symfony.

Ajouter des environnement

La gestion des configurations adoptée pourrait être remplacé par des environnements spécifiques à chaque client. Par exemple APP_ENV=clienta

J'aurais eu également un dossier de configuration par client dans le dossier /config/packages/

Je trouve que cette façon de faire n'est pas propre et soulève des questions.

Comment gérer l'activation du debug ou sa désactivation ? Oui, dans le fichier bundles.php, je ne trouve pas ça propre.

Comment gérer proprement les configurations spécifiques pour un environnement (dev, test, prod) pour un client ? En effet, utiliser les environnements Symfony est possible, mais demande d'en ajouter 3 par client. Exemple : clienta_dev, clienta_test, clienta_prod, clientb_dev, clientb_test, clientb_prod.

La configuration spécifique à un client sera donc dupliquée 3 fois. Une erreur est vite arrivée. La façon de faire retenue permet d'avoir une configuration globale, une configuration globale spécifique à un environnement, une configuration globale pour un client et une configuration spécifique à un environnement pour un client.

Conclusion

Nous allons nous arrêter là pour l'instant, la prochaine étape "Comment gérer les entités Doctrines ?" sera l'objet d'un autre article. Si vous avez des questions, je suis disponible en commentaire ou sur les réseaux sociaux pour en discuter.

Nous avons donc vu comment gérer proprement le thème et la configuration de chaque client avec un nombre très réduire de IF.

Il existe d'autre façon de faire, dites-moi en commentaire quelle est celle que vous préférez.

Author avatar
Jean-Baptiste Nahan

Consultant Expert Web, j'aide les entreprises ayant des difficultés avec leur projet Web (PHP, Symfony, Sylius).

@jbnahan69 | Macintoshplus | Linkedin | JB Dev Labs