<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0" xml:base="https://kgaut.net/">
  <channel>
    <title>Flux RSS Kgaut.NET</title>
    <link>https://kgaut.net/</link>
    <description>Kevin Gautreau - Développeur Drupal Freelance</description>
    <language>fr</language>
    
    <item>
  <title>Uzinasit : retour d'expérience sur mon usine à site Drupal 11</title>
  <link>https://kgaut.net/blog/2026/uzinasit-retour-dexperience-sur-mon-usine-site-drupal-11?pk_campaign=rss</link>
  <description>&lt;p&gt;J'ai profité d'avoir un peu de temps durant l'été 2025 pour commencer à rationaliser l'ensemble de mes sites histoire de m'en simplifier la gestion&lt;/p&gt;Pourquoi une site factory ?&lt;p&gt;Au fil des années, j'ai accumulé des sites Drupal mais aussi d'autres CMS (Wordpress, Joomla), certains pour moi (celui-là, des outils métiers comme mon dashboard de projets, des sideprojects comme mon site de photos de drone.) Mais aussi des sites pour des activités pro d'amis ou membres de la famille.&amp;nbsp;&lt;/p&gt;&lt;p&gt;Le problème quand on a douze sites indépendants, c'est que la maintenance devient compliquée et chronophage... Certains joomla n'étaient plus du tout à jour, plus upgradable, idem pour des wordpress... j'avais encore des drupal 8... un beau bazar.&lt;/p&gt;&lt;p&gt;J'ai donc construit Uzinasit, ma propre "Usine à site" Drupal : une seule base de code, un seul &lt;code&gt;composer.json&lt;/code&gt;, plusieurs bases de données, des configs Drupal isolées par site. Du multi-site Drupal natif, mais industrialisé avec mes propres outils autour (Lando pour le dev local, GitLab CI pour le déploiement, une commande Drush maison pour scaffolder un nouveau site en deux minutes).&lt;/p&gt;&lt;p&gt;Aujourd'hui, ajouter un site se résume à &lt;code&gt;drush uzc&lt;/code&gt;. Mettre à jour Drupal core sur les douze sites se fait en un &lt;code&gt;composer update&lt;/code&gt; et un &lt;code&gt;git push&lt;/code&gt;. C'est de cette mécanique dont je veux parler dans cet article.&lt;/p&gt;&lt;p&gt;Au programme :&lt;/p&gt;La vue d'ensemble de l'architectureComment une seule codebase Drupal sert douze sitesL'environnement de dev local avec LandoLes scripts spécifiques qui me font gagner un temps fouLa commande &lt;code&gt;drush uzc&lt;/code&gt; pour ajouter un siteLe déploiement via GitLab CI (matrix sur les sites)Le workflow de mise à jour Drupal (le vrai gain)1. Vue d'ensemble de l'architecture&lt;p&gt;Avant de plonger, un schéma simplifié de l'infra :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-apache"&gt;┌─ Un repo Git (uzinasit) ─────────────────────────────┐
│                                                       │
│  web/                                                 │
│   ├── core/  + contribs (gérés par Composer)          │
│   ├── modules/&amp;lt;site&amp;gt;/   (1 module custom par site)    │
│   ├── themes/&amp;lt;site&amp;gt;/    (thèmes propres à chaque site)│
│   └── sites/                                          │
│        ├── sites.php          (hostname → dossier)    │
│        ├── settings-shared.php  (le cœur !)           │
│        ├── kgaut/settings.php                         │
│        ├── dashboard/settings.php                     │
│        ├── drone/settings.php                         │
│        └── …                                          │
│                                                       │
│  scripts/        (db-sync, deploy-code, …)            │
│  drush/sites/    (alias Drush par site)               │
│  .lando.yml      (stack Lando dev local)              │
│  .gitlab-ci.yml  (pipeline CI/CD)                     │
└───────────────────────────────────────────────────────┘
              │                          │
              ▼                          ▼
        ┌────────────┐            ┌────────────┐
        │   LANDO    │            │    PROD    │
        │ 12 DBs     │            │ 12 DBs     │
        │ Redis      │            │ Redis      │
        │ Mailpit    │            │ Sentry     │
        │ PMA        │            │ Backups    │
        └────────────┘            └────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Les briques principales :&lt;/p&gt;Drupal 11 en mode multi-site natif (&lt;code&gt;web/sites/&amp;lt;dossier&amp;gt;/&lt;/code&gt;).Composer pour gérer le code : un seul &lt;code&gt;composer.json&lt;/code&gt;, un seul &lt;code&gt;composer.lock&lt;/code&gt;, donc une version unique de Drupal et de chaque contrib pour tous les sites.Lando (surcouche de docker) pour le dev local : 1 service &lt;code&gt;appserver&lt;/code&gt;, 12 services MariaDB, du Redis, du Mailpit, du PhpMyAdmin.GitLab CI pour le déploiement, avec une matrix qui parallélise les jobs sur tous les sites.Drush + des alias par site, pour cibler &lt;code&gt;@kgaut&lt;/code&gt;, &lt;code&gt;@dashboard&lt;/code&gt;, &lt;code&gt;@drone&lt;/code&gt;… aussi bien en local qu'en prod.Sentry pour le monitoring (un projet par site).2. L'infra Drupal - comment une même codebase sert une douzaine de sites&lt;p&gt;Drupal supporte le multi-site nativement depuis très longtemps. Le mécanisme est simple : selon l'URL de la requête, Drupal cherche un dossier &lt;code&gt;web/sites/&amp;lt;quelque-chose&amp;gt;/&lt;/code&gt; contenant un &lt;code&gt;settings.php&lt;/code&gt;, et utilise les paramètres de ce fichier (base de données, fichiers, config…) pour cette requête. Toute la magie d'Uzinasit consiste à rendre ce mécanisme industrialisable.&lt;/p&gt;2.1 &lt;code&gt;sites.php&lt;/code&gt; - router les hostnames vers les dossiers&lt;p&gt;Premier fichier clé, &lt;code&gt;web/sites/sites.php&lt;/code&gt;. C'est lui qui mappe les URL aux dossiers de sites :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;&amp;lt;?php

$sites['kgaut.net'] = 'kgaut'; // URL PROD
$sites['kgaut.lndo.site'] = 'kgaut'; // URL LOCALE

$sites['drone.kgaut.net'] = 'drone';
$sites['drone.lndo.site'] = 'drone';

// … etc, une entrée par environnement, par site
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Pour chaque site j'ai au minimum deux entrées (URL prod + URL locale &lt;code&gt;.lndo.site&lt;/code&gt;), parfois trois (URL preprod). Tout cela pointe vers le même dossier Drupal, qui contient les paramètres spécifiques au site.&lt;/p&gt;2.2 Le fichier &lt;code&gt;settings.php&lt;/code&gt; minimaliste&lt;p&gt;Tous les &lt;code&gt;settings.php&lt;/code&gt; de tous les sites font exactement la même chose. Quatre lignes utiles, pas une de plus. Exemple &lt;code&gt;web/sites/kgaut/settings.php&lt;/code&gt; :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;&amp;lt;?php

$siteFolder = 'kgaut';
$siteFolderUp = strtoupper($siteFolder);
require $app_root . '/sites/settings-shared.php';
if (file_exists($app_root . '/' . $site_path . '/settings.local.php')) {
  include $app_root . '/' . $site_path . '/settings.local.php';
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;C'est tout. Pour le site &lt;code&gt;dashboard&lt;/code&gt;, c'est strictement identique, sauf que &lt;code&gt;$siteFolder = 'dashboard'&lt;/code&gt;. Toute la configuration réelle est centralisée dans &lt;code&gt;settings-shared.php&lt;/code&gt;. C'est ce fichier qui fait le vrai boulot.&lt;/p&gt;2.3 &lt;code&gt;settings-shared.php&lt;/code&gt; - le cœur de l'usine&lt;p&gt;Ce fichier est inclus par les douze &lt;code&gt;settings.php&lt;/code&gt;, et il configure dynamiquement Drupal en fonction du &lt;code&gt;$siteFolder&lt;/code&gt; qui lui est passé. Voici les parties intéressantes (La partie classique du settings.php n'est pas affichée ici, mais présente dans le fichier) :&lt;/p&gt;&lt;p&gt;Connexion DB à partir d'env vars :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;$databases['default']['default'] = [
  'database'         =&amp;gt; getenv($siteFolderUp . '_DB'),
  'username'         =&amp;gt; getenv($siteFolderUp . '_USER'),
  'password'         =&amp;gt; getenv($siteFolderUp . '_PWD'),
  'host'             =&amp;gt; getenv($siteFolderUp . '_HOST'),
  'port'             =&amp;gt; '3306',
  'isolation_level'  =&amp;gt; 'READ COMMITTED',
  'driver'           =&amp;gt; 'mysql',
  'namespace'        =&amp;gt; 'Drupal\\mysql\\Driver\\Database\\mysql',
  'autoload'         =&amp;gt; 'core/modules/mysql/src/Driver/Database/mysql/',
];
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Pour le site &lt;code&gt;kgaut&lt;/code&gt;, récupération des variables d’environnement &lt;code&gt;KGAUT_DB&lt;/code&gt;, &lt;code&gt;KGAUT_USER&lt;/code&gt;, &lt;code&gt;KGAUT_PWD&lt;/code&gt;, &lt;code&gt;KGAUT_HOST&lt;/code&gt;. Pour &lt;code&gt;dashboard&lt;/code&gt;, c'est &lt;code&gt;DASHBOARD_DB&lt;/code&gt;, etc. Un nouveau site : on ajoute 4 lignes dans &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Fichiers privés, tmp, config sync, isolés par site :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;$settings['file_private_path']     = '../files/' . $siteFolder . '/private';
$settings['file_temp_path']        = '../files/' . $siteFolder . '/tmp';
$settings['config_sync_directory'] = '../files/' . $siteFolder . '/config/sync';
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Chaque site a ses propres dossiers &lt;code&gt;files/&amp;lt;site&amp;gt;/{private,tmp,config,dumps}&lt;/code&gt;. Aucun risque qu'un site écrive dans les fichiers d'un autre.&lt;/p&gt;&lt;p&gt;Redis optionnel, activé par site :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;$redisKey = $siteFolderUp . '_REDIS_ENABLED';
if (getenv($redisKey) &amp;amp;&amp;amp; (bool) getenv($redisKey) === TRUE) {
  $settings['redis.connection']['host']      = getenv('REDIS_HOST');
  $settings['redis.connection']['port']      = getenv('REDIS_PORT');
  $settings['redis.connection']['interface'] = getenv('REDIS_INTERFACE');
  $settings['container_yamls'][]             = DRUPAL_ROOT . '/sites/redis.services.yml';
  $settings['cache']['default']              = 'cache.backend.redis';
  $settings['cache_prefix']['default']       = $siteFolder . '_';
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Le préfixe de cache est dérivé de &lt;code&gt;$siteFolder&lt;/code&gt;, donc deux sites peuvent partager la même instance Redis sans collision. Les sites qui en ont besoin (typiquement le dashboard, très sollicité) activent Redis via &lt;code&gt;DASHBOARD_REDIS_ENABLED=true&lt;/code&gt; dans le &lt;code&gt;.env&lt;/code&gt;. Les petits sites qui n'en ont pas besoin tournent sur cache base de données classique.&lt;/p&gt;&lt;p&gt;Sentry par site :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;if (getenv($siteFolderUp . '_SENTRY_DSN')) {
  $_SERVER['SENTRY_DSN'] = getenv($siteFolderUp . '_SENTRY_DSN');
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Chaque site a son propre projet Sentry. Quand kgaut.net lève une exception, je la vois remonter dans le projet Sentry &lt;code&gt;kgaut&lt;/code&gt;, pas dans celui du dashboard.&lt;/p&gt;&lt;p&gt;Indicateur d'environnement :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;if (getenv('APP_ENV')) {
  $environements = [
    'dev'     =&amp;gt; ['name' =&amp;gt; 'dev',     'bg_color' =&amp;gt; '#006600', 'fg_color' =&amp;gt; '#ffffff'],
    'preprod' =&amp;gt; ['name' =&amp;gt; 'Staging', 'bg_color' =&amp;gt; '#FF9900', 'fg_color' =&amp;gt; '#000000'],
    'prod'    =&amp;gt; ['name' =&amp;gt; 'Prod',    'bg_color' =&amp;gt; '#ef5350', 'fg_color' =&amp;gt; '#000000'],
  ];
  if (isset($environements[getenv('APP_ENV')])) {
    $config['environment_indicator.indicator'] = $environements[getenv('APP_ENV')];
  }
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Combiné avec le module &lt;code&gt;environment_indicator&lt;/code&gt;, ça m'affiche un bandeau vert en dev, orange en preprod, rouge en prod dans l'admin Drupal. Basique, mais ça évite les erreurs du « oups je viens de faire la modif en prod au lieu du local ».&lt;/p&gt;&lt;p&gt;Config split par environnement :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;$config["config_split.config_split.".getenv("APP_ENV")]["status"] = TRUE;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;J'ai des splits &lt;code&gt;config_split.dev&lt;/code&gt;, &lt;code&gt;config_split.preprod&lt;/code&gt;, &lt;code&gt;config_split.prod&lt;/code&gt; qui activent ou désactivent automatiquement des modules selon l'env (par exemple &lt;code&gt;devel&lt;/code&gt; en dev seulement).&lt;/p&gt;&lt;p&gt;Et en dev seulement, on charge les services de développement et on route les mails vers Mailpit :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;if (getenv('APP_ENV') &amp;amp;&amp;amp; getenv('APP_ENV') === 'dev') {
  $settings['container_yamls'][] = DRUPAL_ROOT . '/sites/development.yml';
  $config['system.logging']['error_level']                = 'verbose';
  $config['symfony_mailer.settings']['default_transport'] = 'mailpit';
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Tout ça est dans un seul fichier, qui sert les douze sites. Quand j'ajoute une nouvelle fonctionnalité de configuration partagée (ex: ajouter un Sentry, routage des mails), il bénéficie automatiquement à tous les sites.&lt;/p&gt;2.4 Le &lt;code&gt;.env&lt;/code&gt;, la config par site&lt;pre&gt;&lt;code class="language-bash"&gt;APP_ENV='dev'

KGAUT_USER="drupal11"
KGAUT_PWD="drupal11"
KGAUT_HOST="database_kgaut"
KGAUT_DB="kgaut"
KGAUT_SENTRY_DSN="https://…@sentry.io/…"

DASHBOARD_USER="drupal11"
DASHBOARD_PWD="drupal11"
DASHBOARD_HOST="database_dashboard"
DASHBOARD_DB="dashboard"
DASHBOARD_REDIS_ENABLED=true
DASHBOARD_SENTRY_DSN="https://…@sentry.io/…"

REDIS_HOST=redis
REDIS_PORT=6379
REDIS_INTERFACE=PhpRedis
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;En dev, tous les sites partagent le même utilisateur MySQL &lt;code&gt;drupal11/drupal11&lt;/code&gt;. En prod, j'ai un utilisateur MySQL distinct par base, avec un mot de passe distinct, pour une isolation plus forte.&lt;/p&gt;2.5 Les alias Drush - pouvoir cibler chaque site en CLI&lt;p&gt;Pour cibler un site en ligne de commande, Drush utilise des alias. J'ai un fichier &lt;code&gt;drush/sites/&amp;lt;site&amp;gt;.site.yml&lt;/code&gt; par site, par exemple &lt;code&gt;drush/sites/kgaut.site.yml&lt;/code&gt; :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;dev:
  root: /app
  uri: https://kgaut.lndo.site&lt;/code&gt;&lt;/pre&gt;2.6 Modules custom - un par site, un mutualisé&lt;p&gt;L'organisation des modules est claire :&lt;/p&gt;&lt;code&gt;web/modules/&amp;lt;site&amp;gt;/&lt;/code&gt; : module propre à chaque site, contient le code métier spécifique (par exemple &lt;code&gt;web/modules/dashboard/&lt;/code&gt; contient le code de mon site de suivi de projet, &lt;code&gt;web/modules/dronestagram/&lt;/code&gt; contient mon module pour mon site de photos drones).&lt;p&gt;Côté thèmes, même logique, chaque site a son thème.&lt;/p&gt;3. L'infra Lando pour le dev local&lt;p&gt;Pour faire tourner les douze sites en local, j'utilise Lando. C'est une surcouche sur Docker qui simplifie énormément la déclaration de stacks de dev, avec une configuration YAML lisible et des « recipes » toutes faites (Drupal 11 inclus).&lt;/p&gt;&lt;p&gt;L'alternative serait un &lt;code&gt;docker-compose.yml&lt;/code&gt; à la main. Lando me donne en plus :&lt;/p&gt;Un proxy automatique qui résout les domaines &lt;code&gt;.lndo.site&lt;/code&gt; en HTTPS valide (certificat auto-signé installé dans le store du système)Une notion de « tooling » qui définit des commandes haut niveau (&lt;code&gt;lando drush&lt;/code&gt;, &lt;code&gt;lando phpstan&lt;/code&gt;…)3.1 Le &lt;code&gt;.lando.yml&lt;/code&gt; - la stack complète&lt;p&gt;Voici le squelette de mon &lt;code&gt;.lando.yml&lt;/code&gt; (j'ai raccourci la partie services DB) :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;name: uzinasit
recipe: drupal11
config:
  webroot: web

proxy:
  appserver:
    - kgaut.lndo.site
    - dashboard.lndo.site
    - drone.lndo.site
    ...
  mailpit:
    - 'mail.lndo.site:8025'
  pma:
    - pma.lndo.site

services:

  node:
    type: 'node:22'
    ssl: true
    sslExpose: true
    portforward: 4444

  mailpit:
    api: 3
    type: lando
    services:
      image: axllent/mailpit
      ports: [8025, 1025]
      environment:
        MP_MAX_MESSAGES: 5000
        MP_SMTP_AUTH_ACCEPT_ANY: 1

  cache:
    type: redis
    persist: true
  redis:
    type: 'redis:5'

  pma:
    type: phpmyadmin
    hosts:
      - database_kgaut
      - database_dashboard
      - database_drone
      # … les 12

  database_kgaut:
    type: 'mariadb:10.11'
    portforward: true
    creds: { user: drupal11, password: drupal11, database: kgaut }
  database_dashboard:
    type: 'mariadb:10.11'
    portforward: true
    creds: { user: drupal11, password: drupal11, database: dashboard }
  # … etc, une entrée par site
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Les points à noter :&lt;/p&gt;Node est un service à part, utilisé pour les builds front (SASS, gulp...)Mailpit capture tous les mails envoyés en dev — visible sur &lt;code&gt;mail.lndo.site:8025&lt;/code&gt;.Une base MariaDB 10.11 par site.&amp;nbsp;PhpMyAdmin centralise l'accès aux douze bases sur &lt;code&gt;pma.lndo.site&lt;/code&gt;.3.2 Pourquoi douze bases différentes plutôt qu'une seule avec préfixes ?&lt;p&gt;C'est un choix d'architecture. Drupal sait travailler avec des préfixes de tables (&lt;code&gt;prefix&lt;/code&gt; dans &lt;code&gt;$databases&lt;/code&gt;), donc on pourrait mutualiser une seule instance MariaDB. Mais j'ai choisi une instance par site, pour ces raisons :&lt;/p&gt;Cohérence avec la prod : en prod chaque site est sur sa propre base, avec son propre utilisateur MySQL. Faire pareil en local évite les surprises.Isolation totale : &lt;code&gt;drop database kgaut;&lt;/code&gt; ne touchera jamais le dashboard.Dumps simples : un fichier &lt;code&gt;.sql.gz&lt;/code&gt; par site, qu'on importe sans réfléchir à des préfixes. Le même dump fonctionne en local et en prod.Performance : pas de mutualisation, donc pas de contention. La pause d'un dump n'impacte que le site concerné.3.3 Démarrer la stack&lt;p&gt;Une fois &lt;code&gt;.env&lt;/code&gt; rempli :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;lando start&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Et voilà, douze Drupal accessibles en HTTPS sur &lt;code&gt;https://kgaut.lndo.site&lt;/code&gt;, &lt;code&gt;https://dashboard.lndo.site&lt;/code&gt;, etc. Ajouter un nouveau site ne demandera pas de toucher à cette config — la commande &lt;code&gt;drush uzc&lt;/code&gt; (qu'on voit plus loin) le fera automatiquement.&lt;/p&gt;4. Les scripts spécifiques - le vrai gain de productivité quotidien&lt;p&gt;Lando offre une section &lt;code&gt;tooling&lt;/code&gt; qui permet de définir des commandes haut niveau. Toutes les commandes du tableau ci-dessous se lancent en &lt;code&gt;lando &amp;lt;commande&amp;gt;&lt;/code&gt;, depuis la racine du projet :&lt;/p&gt;Commande LandoCe qu'elle fait&lt;code&gt;lando front:build&lt;/code&gt;&lt;code&gt;npm i&lt;/code&gt; + build des thèmes (je pourrais isoler, mais j'ai choisi la simplicité&lt;code&gt;lando db-export-all&lt;/code&gt;Dump SQL horodaté de chaque base, dans &lt;code&gt;files/&amp;lt;site&amp;gt;/dumps/&lt;/code&gt;&lt;code&gt;lando drush-all&lt;/code&gt;Exécute la même commande Drush sur les 12 sites, séquentiellement&lt;code&gt;lando db-get-all&lt;/code&gt;Rapatrie le dernier dump prod de tous les sites via SSH&lt;code&gt;lando db-import-all&lt;/code&gt;Importe localement le dernier dump &lt;code&gt;.sql.gz&lt;/code&gt; de tous les sites&lt;code&gt;lando db-sync &amp;lt;site&amp;gt;&lt;/code&gt;&lt;code&gt;db-get&lt;/code&gt; + &lt;code&gt;db-import&lt;/code&gt; + &lt;code&gt;drush deploy&lt;/code&gt; enchaînés pour un seul site&lt;code&gt;lando db-sync-all&lt;/code&gt;Idem mais pour tous les sites détectés&lt;code&gt;lando phpstan&lt;/code&gt;Analyse PHPStan sur les modules custom&lt;code&gt;lando phpcs&lt;/code&gt;Analyse PHPCS&lt;code&gt;lando rector&lt;/code&gt;Rector (refactoring automatique guidé)&lt;code&gt;lando phpunit&lt;/code&gt;PHPUnit&lt;code&gt;lando grumphp&lt;/code&gt;Suite complète CS + PHPStan + composer check (utilisé en hook pre-commit)4.1 &lt;code&gt;db-sync&lt;/code&gt; - Rapatriement de la base de prod en local&lt;p&gt;Un simple :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;lando db-sync dashboard
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Et 30 secondes plus tard, ma base locale &lt;code&gt;dashboard&lt;/code&gt; contient le dernier dump prod, et le &lt;code&gt;drush deploy&lt;/code&gt; a tourné. Je suis aligné à la prod, prêt à développer.&lt;/p&gt;&lt;p&gt;Le script &lt;code&gt;scripts/db-sync.sh&lt;/code&gt; est volontairement court — il orchestre deux scripts plus simples :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;#!/usr/bin/env bash
set -euo pipefail

SITE="${1:-}"
DRY_RUN=false
NO_DEPLOY=false

# parse des flags --dry-run et --no-deploy
for arg in "${@:2}"; do
  case "$arg" in
    --dry-run)   DRY_RUN=true ;;
    --no-deploy) NO_DEPLOY=true ;;
  esac
done

GET_SCRIPT="./scripts/db-get.sh"
IMPORT_SCRIPT="./scripts/db-import.sh"

echo "--- Récupération du dump ---"
"$GET_SCRIPT" "$SITE" || echo "Pas de nouveau dump distant, on utilise le local."

echo "--- Import du dump ---"
IMPORT_ARGS=("$SITE")
[[ "$NO_DEPLOY" == true ]] &amp;amp;&amp;amp; IMPORT_ARGS+=(--no-deploy)
"$IMPORT_SCRIPT" "${IMPORT_ARGS[@]}"
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Les deux briques qu'il chaîne :&lt;/p&gt;&lt;p&gt;&lt;code&gt;db-get.sh&lt;/code&gt; se connecte en SSH à mon serveur, cherche le dump &lt;code&gt;.sql.gz&lt;/code&gt; le plus récent dans le dossier &lt;code&gt;files/&amp;lt;site&amp;gt;/dumps/&lt;/code&gt; distant, et le rapatrie en local via &lt;code&gt;rsync&lt;/code&gt; (avec fallback &lt;code&gt;scp&lt;/code&gt; si rsync absent) :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;REMOTE_BASE="/home/uzinasit/public_html/files/${SITE}/dumps"
LOCAL_BASE="./files/${SITE}/dumps"

LATEST_REMOTE_FILE=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
  "ls -1t ${REMOTE_BASE}/*.sql.gz 2&amp;gt;/dev/null | head -n1")

rsync -avz --progress \
  "${REMOTE_USER}@${REMOTE_HOST}:${LATEST_REMOTE_FILE}" \
  "${LOCAL_BASE}/"
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;db-import.sh&lt;/code&gt; importe le &lt;code&gt;.sql.gz&lt;/code&gt; directement via &lt;code&gt;mysql&lt;/code&gt; sans extraction intermédiaire (&lt;code&gt;zcat | mysql&lt;/code&gt;), puis lance &lt;code&gt;drush deploy&lt;/code&gt; :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;MYSQL_CMD=$(drush "@$SITE" sql:connect 2&amp;gt;/dev/null)
zcat "$LATEST_FILE" | eval "${MYSQL_CMD} --skip-ssl"

echo "Dump importé pour $SITE"
if [[ "$NO_DEPLOY" == true ]]; then
  echo "Drush deploy ignoré (--no-deploy)"
else
  drush "@$SITE" deploy
fi
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Le &lt;code&gt;drush deploy&lt;/code&gt; est important : il importe la config Drupal locale (&lt;code&gt;config:import&lt;/code&gt;), passe les hooks update (&lt;code&gt;updatedb&lt;/code&gt;), reconstruit le cache. Après ça, le site local correspond exactement à l'état du code courant + les données de prod.&lt;/p&gt;&lt;p&gt;Les flags &lt;code&gt;--dry-run&lt;/code&gt; (vérifier sans rien faire) et &lt;code&gt;--no-deploy&lt;/code&gt; (importer sans lancer &lt;code&gt;drush deploy&lt;/code&gt;) sont là pour les cas particuliers, typiquement, quand je veux la base prod mais sans réimporter le config split « dev ».&lt;/p&gt;4.2 &lt;code&gt;db-sync-all&lt;/code&gt; - Le gros reset&lt;p&gt;Si je reviens d'une semaine de vacances et que je veux remettre à plat toute mon usine locale, je lance :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;lando db-sync-all
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Le script découvre tout seul la liste des sites en scannant &lt;code&gt;drush/sites/*.site.yml&lt;/code&gt;, et appelle &lt;code&gt;db-sync.sh&lt;/code&gt; pour chacun :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;mapfile -t ALIAS_FILES &amp;lt; &amp;lt;(printf '%s\n' "${DRUSH_SITES_DIR}"/*.site.yml | sort)

SITES=()
for f in "${ALIAS_FILES[@]}"; do
  alias_name="${$(basename -- "$f")%.site.yml}"
  case "$alias_name" in
    self|uzinasit|mbc) continue ;;  # skip les techniques
    *) SITES+=("$alias_name") ;;
  esac
done

echo "Sites détectés (${#SITES[@]}): ${SITES[*]}"

for site in "${SITES[@]}"; do
  if "$SYNC_ONE_SCRIPT" "$site" "${EXTRA_ARGS[@]}"; then
    SUCCESS=$((SUCCESS + 1))
  else
    FAILED=$((FAILED + 1))
  fi
done

echo "Résumé sync-all: ${SUCCESS} succès, ${FAILED} échec(s)."
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Quand j'ajoute un nouveau site avec &lt;code&gt;drush uzc&lt;/code&gt;, son alias &lt;code&gt;drush/sites/&amp;lt;site&amp;gt;.site.yml&lt;/code&gt; est créé automatiquement → il est inclus automatiquement au prochain &lt;code&gt;db-sync-all&lt;/code&gt;. Aucune liste à maintenir nulle part.&lt;/p&gt;5. Ajouter un nouveau site avec &lt;code&gt;drush uzc&lt;/code&gt;&lt;p&gt;Ajouter un nouveau site dans Uzinasit demande sept modifications de fichiers dans le bon ordre :&lt;/p&gt;&lt;code&gt;web/sites/sites.php&lt;/code&gt; (mapping hostname → dossier)&lt;code&gt;drush/sites/&amp;lt;alias&amp;gt;.site.yml&lt;/code&gt; (alias Drush)&lt;code&gt;.lando.yml&lt;/code&gt; (service MariaDB, proxy, PMA, tooling…)&lt;code&gt;.env&lt;/code&gt; (4 variables)&lt;code&gt;files/&amp;lt;alias&amp;gt;/&lt;/code&gt; (création des dossiers)&lt;code&gt;web/sites/&amp;lt;alias&amp;gt;/&lt;/code&gt; (settings.php + services.yml)&lt;code&gt;.gitlab-ci.yml&lt;/code&gt; (ajout dans la matrix)&lt;p&gt;J'ai fait ça a la main quelques fois, et j'en ai eu marre, oubli, perte de temps...&lt;/p&gt;&lt;p&gt;J'ai donc écrit une commande Drush qui fait tout ça automatiquement. Elle vit dans &lt;code&gt;drush/Commands/uzinasit/CreateCommand.php&lt;/code&gt; et s'appelle &lt;code&gt;drush uzinasit:create&lt;/code&gt;, alias &lt;code&gt;drush uzc&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Le déroulé interactif :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-apache"&gt;$ drush uzc
Enter the site alias (e.g., ulm) [ulm]: monnouveausite
Enter the local URL (must end with .lndo.site) [monnouveausite.lndo.site]:
Enter the production URL (without the procol) [monnouveausite.uzinasit.kgaut.net]: monnouveausite.kgaut.net

 Site Information
 ────────────────
 ┌────────────────┬───────────────────────────────────┐
 │ Parameter      │ Value                             │
 ├────────────────┼───────────────────────────────────┤
 │ Site Alias     │ monnouveausite                    │
 │ Local URL      │ monnouveausite.lndo.site          │
 │ Production URL │ monnouveausite.kgaut.net          │
 └────────────────┴───────────────────────────────────┘

 Do you want to create this site? (yes/no) [yes]:

Updating sites.php... ✅ sites.php updated.
Creating Drush alias file... ✅ Drush alias file created.
Updating .lando.yml... ✅ .lando.yml updated.
Updating .env file... ✅ .env file updated.
Creating site files directory... ✅ Site files directory created.
Creating site configuration directory... ✅ Site configuration directory created and settings.php updated.
Updating .gitlab-ci.yml... ✅ .gitlab-ci.yml updated with new site alias.

[OK] Site 'monnouveausite' has been successfully created!
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Sous le capot, la commande utilise &lt;code&gt;Symfony\Component\Yaml\Yaml&lt;/code&gt; pour lire/écrire &lt;code&gt;.lando.yml&lt;/code&gt; et &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; proprement. Voici l'extrait qui modifie le &lt;code&gt;.lando.yml&lt;/code&gt; pour ajouter le service MariaDB et le tooling associé :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;$landoYml = Yaml::parseFile($landoYmlPath);

// Ajoute l'URL locale au proxy appserver
$landoYml['proxy']['appserver'][] = $localUrl;

// Ajoute un nouveau service MariaDB pour ce site
$dbServiceName = "database_$alias";
$landoYml['services'][$dbServiceName] = [
  'type'        =&amp;gt; 'mariadb:10.11',
  'portforward' =&amp;gt; true,
  'creds'       =&amp;gt; [
    'user'     =&amp;gt; 'drupal11',
    'password' =&amp;gt; 'drupal11',
    'database' =&amp;gt; $alias,
  ],
];

// Inscrit la nouvelle base dans PhpMyAdmin
$landoYml['services']['pma']['hosts'][] = $dbServiceName;

// Ajoute le site dans tous les tooling concernés
$landoYml['tooling']['db-export-all']['cmd'][] = [
  $dbServiceName =&amp;gt; "/helpers/sql-export.sh /app/files/$alias/dumps/`date +%Y-%m-%d_%H-%M-%S`-$alias.sql"
];
$landoYml['tooling']['drush-all']['cmd'][]     = ["drush @$alias"];
$landoYml['tooling']['db-get-all']['cmd'][]    = "./scripts/db-get.sh $alias";
$landoYml['tooling']['db-import-all']['cmd'][] = "./scripts/db-import.sh $alias";

file_put_contents($landoYmlPath, Yaml::dump($landoYml, 10, 2));
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Et la mise à jour de la matrix GitLab CI utilise une regex sur la liste des sites :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;if (preg_match('/- SITE: \[(.*?)\]/', $content, $matches)) {
  $sitesList = $matches[1];
  $sites = array_map('trim', explode(',', $sitesList));

  if (!in_array("'$alias'", $sites)) {
    $sites[] = "'$alias'";
    $newSitesList = implode(', ', $sites);
    $newContent = preg_replace('/- SITE: \[(.*?)\]/', "- SITE: [$newSitesList]", $content);
    file_put_contents($gitlabCiPath, $newContent);
  }
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Pour les deux dossiers à créer (&lt;code&gt;files/&amp;lt;alias&amp;gt;/&lt;/code&gt; et &lt;code&gt;web/sites/&amp;lt;alias&amp;gt;/&lt;/code&gt;), la commande copie des templates de scaffolding depuis &lt;code&gt;drush/Commands/uzinasit/scaffolding/&lt;/code&gt; :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;$sourceDir = dirname(__FILE__) . "/scaffolding/sites_SITE";
$targetDir = "$projectRoot/web/sites/$alias";
$fs-&amp;gt;mirror($sourceDir, $targetDir);

// Remplace le placeholder dans le settings.php scaffoldé
$content = file_get_contents("$targetDir/settings.php");
$content = str_replace("\$siteFolder = 'ulm';", "\$siteFolder = '$alias';", $content);
file_put_contents("$targetDir/settings.php", $content);
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Une fois la commande terminée, il ne me reste qu'à faire :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;lando restart                    # pour prendre en compte la nouvelle DB&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Et voilà. Site ajouté en deux minutes.&lt;/p&gt;6. Le déploiement - GitLab CI et la matrice sur les sites&lt;p&gt;Tout le code vit dans un repo GitLab (j'auto-héberge mon GitLab, mais cela fonctionnerait pareil sur gitlab.com). Le déploiement est piloté par &lt;code&gt;.gitlab-ci.yml&lt;/code&gt;, qui s'appuie sur mes propres templates partagés entre plusieurs projets : &lt;code&gt;kgaut/gitlab-ci-templates&lt;/code&gt;.&lt;/p&gt;6.1 La structure du pipeline&lt;pre&gt;&lt;code class="language-yaml"&gt;variables:
  CI_TEMPLATE_VERSION: &amp;amp;CI_TEMPLATE_VERSION 0.4.21
  QA_DOCKER_IMAGE: wodby/drupal-php:8.3-dev
  DAYS_DUMP_TO_KEEP: 7
  SENTRY_ORG: 'kgautnet'

include:
  - project: kgaut/gitlab-ci-templates
    ref: *CI_TEMPLATE_VERSION
    file:
      - '/templates/generic/stages-variables-extends.yml'
      - '/templates/drupal/backup.yml'
      - '/templates/drupal/deploy.yml'
      - '/templates/generic/deploy-tracking.yml'
      - '/templates/qa/qa.yml'
      - '/templates/qa/phpstan.yml'
      - '/templates/qa/phpcs.yml'
      - '/templates/qa/phpunit.yml'
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Mes templates externalisés gèrent tout le boilerplate (SSH, stages, conventions de nommage). Le pipeline a 5 stages : &lt;code&gt;QA → predeploy → deploy → postdeploy → scheduled&lt;/code&gt;.&lt;/p&gt;6.2 La matrix - les jobs en parallèle, par site&lt;p&gt;C'est le mécanisme central qui permet de paralléliser sur les sites :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;.drush-aliases:
  parallel:
    matrix:
      - SITE: ['kgaut', 'dashboard', ...]

.sentry-sites:
  parallel:
    matrix:
      - SITE: ['kgaut', 'dashboard', ...]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Tout job qui &lt;code&gt;extends: .drush-aliases&lt;/code&gt; est automatiquement lancé une fois par site, en parallèle, avec la variable &lt;code&gt;$SITE&lt;/code&gt; qui prend chaque valeur. Donc &lt;code&gt;prod:deploy-config&lt;/code&gt;, &lt;code&gt;prod:clear-cache&lt;/code&gt;, &lt;code&gt;prod:postdeploy:backup&lt;/code&gt;, &lt;code&gt;prod:scheduled-backup&lt;/code&gt; — toutes ces étapes tournent en parallèle sur 11 jobs (un par site).&lt;/p&gt;&lt;p&gt;Et quand j'ajoute un nouveau site avec &lt;code&gt;drush uzc&lt;/code&gt;, il est automatiquement ajouté à cette matrix (la commande Drush modifie le &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; toute seule, on l'a vu juste avant).&lt;/p&gt;6.3 Les jobs clés&lt;p&gt;QA (stage &lt;code&gt;QA&lt;/code&gt;) — tourne sur chaque merge request et push sur main :&lt;/p&gt;&lt;code&gt;phpstan&lt;/code&gt; : analyse statique sur les modules custom&lt;code&gt;phpcs&lt;/code&gt; : coding standards Drupal&lt;code&gt;phpunit&lt;/code&gt; : tests unitaires&lt;p&gt;&lt;code&gt;assets:generation&lt;/code&gt; (stage &lt;code&gt;predeploy&lt;/code&gt;) compile les CSS et JS sur runner Node 22 :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;assets:generation:
  image: node:22
  stage: predeploy
  script:
    - npm i
    - npm run sass-build-kgaut
    - npm run sass-build-wam
    - npm run sass-build-daash
    - npm i --prefix ./web/libraries/
    - npm i --prefix ./web/themes/custom/yfdev
    - npm run build --prefix ./web/themes/custom/yfdev
    - ...
  artifacts:
    paths:
      - web/themes/kgaut_2024/css
      - web/themes/daash/css
      - web/themes/wam_theme/css
      - web/themes/custom/yfdev/dist
      - web/libraries/node_modules
      - ...
    expire_in: 20 minutes
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Les CSS générés sont publiés en artefacts (durée de vie 20 min, juste assez pour le job suivant).&lt;/p&gt;&lt;p&gt;&lt;code&gt;assets:deploy&lt;/code&gt; (stage &lt;code&gt;deploy&lt;/code&gt;) pousse les assets compilés en SSH avec &lt;code&gt;rsync&lt;/code&gt; :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;assets:deploy:
  image: alpine
  stage: deploy
  before_script:
    - apk update &amp;amp;&amp;amp; apk add openssh git curl rsync
  script:
    - rsync -avhzi --delete --stats web/themes/kgaut_2024/css \
        -e "ssh -p $SSH_PORT" $SSH_USER@$SSH_HOST:$PROJECT_ROOT/web/themes/kgaut_2024
    - rsync -avhzi --delete --stats web/themes/daash/css \
        -e "ssh -p $SSH_PORT" $SSH_USER@$SSH_HOST:$PROJECT_ROOT/web/themes/daash
    # … etc
  needs: [assets:generation, prod:deploy]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;prod:deploy&lt;/code&gt; (stage &lt;code&gt;deploy&lt;/code&gt;) c'est ici qu'on déploie le code PHP. Ce job SSH dans la prod et exécute &lt;code&gt;scripts/deploy-code.sh&lt;/code&gt; :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;.deploy:
  stage: deploy
  extends: [.ssh]
  script:
    - $SSH_CHAIN 'bash -s' &amp;lt; ./scripts/deploy-code.sh \
        $PROJECT_ROOT $DRUPAL_SITE_PATH $DRUSH_BIN \
        $CI_ENVIRONMENT_NAME $CI_PIPELINE_IID $CI_COMMIT_REF_NAME
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Le script &lt;code&gt;deploy-code.sh&lt;/code&gt; est volontairement minimaliste :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;#!/bin/bash
set -e

PROJECT_ROOT="$1"
CI_ENVIRONMENT_NAME="$4"
CI_PIPELINE_IID="$5"
CI_COMMIT_REF_NAME="$6"

cd "$PROJECT_ROOT"

# Passer les dossiers settings en écriture pendant la mise à jour
chmod +w "$PROJECT_ROOT/web/sites/default"
chmod +w "$PROJECT_ROOT/web/sites/default/settings.php"
chmod +w "$PROJECT_ROOT/web/sites/monboncoin"
chmod +w "$PROJECT_ROOT/web/sites/monboncoin/settings.php"
# … pour chaque site

if [ $CI_ENVIRONMENT_NAME = "prod" ]; then
  TAG="$6"
  git fetch --tags
  git checkout "$TAG"
else
  git fetch --all
  git reset --hard origin/$CI_COMMIT_REF_NAME
fi

composer install --no-dev

# Restaurer les perms read-only
chmod ugo-w "$PROJECT_ROOT/web/sites/default"
chmod ugo-w "$PROJECT_ROOT/web/sites/default/settings.php"
# …
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;En prod : on déploie sur un tag. En staging : sur la branche courante. Et &lt;code&gt;composer install --no-dev&lt;/code&gt; garantit qu'on tourne sans les dépendances de dev (phpstan, phpcs…). Tout le reste — la config Drupal à importer, les caches à vider, les backups — est délégué à des jobs GitLab qui suivent.&lt;/p&gt;&lt;p&gt;&lt;code&gt;prod:deploy-config&lt;/code&gt; pour chaque site en parallèle, exécute &lt;code&gt;drush deploy&lt;/code&gt; (qui fait &lt;code&gt;updatedb&lt;/code&gt; + &lt;code&gt;config:import&lt;/code&gt; + &lt;code&gt;cache:rebuild&lt;/code&gt;) :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;prod:deploy-config:
  extends: [.prod, .ssh, .drush-aliases]
  script:
    - $SSH_CHAIN 'bash -s' &amp;lt; ./scripts/deploy-config.sh \
        $PROJECT_ROOT $DRUPAL_SITE_PATH $DRUSH_BIN \
        $CI_ENVIRONMENT_NAME $CI_PIPELINE_IID \
        $CI_COMMIT_REF_NAME "@$SITE.main" $SITE
  needs: [prod:deploy]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;prod:clear-cache&lt;/code&gt;, &lt;code&gt;drush cr&lt;/code&gt; sur chaque site, en parallèle :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;prod:clear-cache:
  extends: [.prod, .drush-aliases, .ssh]
  stage: postdeploy
  script:
    - $SSH_CHAIN "$PROJECT_ROOT/$DRUSH_BIN $DRUSH_ALIAS cr"
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;prod:postdeploy:backup&lt;/code&gt;, dump SQL horodaté de chaque site juste après le deploy. Permet de revenir en arrière facilement si une release casse quelque chose :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;prod:postdeploy:backup:
  extends: [.ssh, .backup, .drush-aliases, .prod]
  script:
    - $SSH_CHAIN "$PROJECT_ROOT/$DRUSH_BIN @$SITE.main sql-dump \
        | gzip -9 &amp;gt; $PROJECT_ROOT/files/$SITE/dumps/`date +%Y-%m-%d_%H-%M-%S`-$SITE-post-$CI_COMMIT_REF_NAME.sql.gz"
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;prod:scheduled-backup&lt;/code&gt;, tourne sur un cron GitLab (chaque nuit). Idem mais en mode planifié.&lt;/p&gt;&lt;p&gt;&lt;code&gt;prod:scheduled-clean&lt;/code&gt;, purge les dumps de plus de 7 jours :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;script:
  - $SSH_CHAIN "find $PROJECT_ROOT/files/$SITE/dumps/ \
      -type f -mtime +$DAYS_DUMP_TO_KEEP -name '*.sql.gz' -delete"
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;prod:sentry-release&lt;/code&gt;, crée une release Sentry par site avec les commits associés :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;prod:sentry-release:
  variables:
    RELEASE: "$CI_COMMIT_TAG"
    SENTRY_PROJECT: "$SITE"
  extends: [.sentry-sites, .prod, .sentry-release]
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;À la fin du pipeline, j'ai :&lt;/p&gt;Le code déployé sur les 12 sitesLes CSS et JS recompilés et "rsyncés"La config Drupal importée sur chaque siteLes caches vidésUn dump SQL pre-deploy et un dump post-deploy par siteUne release Sentry par site avec les commits associés à cette version&lt;p&gt;Et tout ça tourne en ~5 à 10 minutes selon la taille de la mise à jour, grâce à la parallélisation par matrice.&lt;/p&gt;7. Le workflow de mise à jour Drupal&lt;p&gt;C'est l'argument principal de cette archi. Voyons concrètement comment ça se passe pour une mise à jour de Drupal core :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;cd uzinasit
composer update drupal/core --with-dependencies
lando drush-all updb -y # mise à jour de la DB
lando drush-all cex # export de la config
# On teste via les tests unitaires et manuels
git commit -am "feat(core): update Drupal 11.x.y"
git tag x.y.z
git push
git push --tags
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Et la CI fait le reste : QA, build des assets, déploiement code sur la prod (un seul &lt;code&gt;composer install&lt;/code&gt;), &lt;code&gt;drush deploy&lt;/code&gt; en matrice sur les 12 sites, vidage de cache, backup post-deploy, releases Sentry. Le tout en moins de 10 minutes, en parallèle.&lt;/p&gt;&lt;p&gt;Et le &lt;code&gt;composer.lock&lt;/code&gt; étant unique, j'ai la garantie absolue que mes 12 sites tournent exactement avec la même version de Drupal core et des mêmes contribs. Plus de dérive entre sites.&lt;/p&gt;&lt;p&gt;C'est ce que je voulais. Maintenance routinière → quasi zéro temps. La dernière fois qu'une vulnérabilité critique Drupal core a été publiée, j'ai appliqué la mise à jour en 5 minutes. Sans cette archi, j'aurais bloqué une demi-journée.&lt;/p&gt;&lt;p&gt;Idem pour les modules contrib : &lt;code&gt;composer require drupal/un_module&lt;/code&gt; rend le module disponible pour les 12 sites. Charge à chaque site d'activer ce module ou pas (via son &lt;code&gt;core.extension.yml&lt;/code&gt; dans &lt;code&gt;files/&amp;lt;site&amp;gt;/config/sync/&lt;/code&gt;).&lt;/p&gt;Pour conclure&lt;p&gt;Je suis très content de cette infra,&amp;nbsp;&lt;/p&gt;La mise en place d'un nouveau site est maintenant très rapideMes sites sont maintenant tout le temps à jourBackup simplifié pour l'ensemble des sites&lt;p&gt;Quelques limites :&lt;/p&gt;Quand je fais des modifs concernant un site, c'est 12 sites qui sont publié, backupé, caches vidés... peut-être réflechir à un moyen de limiter le déploiement à un seul site quand ça n'est pas de la mise à jour de module / libEvidement je ne gère aucun site de client sur cette infrastructure.&lt;p&gt;Prochaine piste que le voudrais creuser : Les Drupal recipes pour scaffolder un site avec un set de modules prédéfinis (Actu, blog, site vitrine...)&lt;/p&gt;&lt;p&gt;N'hésitez-pas si vous avez des suggestions ou des questions&lt;/p&gt;</description>
  <pubDate>Fri, 29 May 2026 12:18:28 +0200</pubDate>
    <dc:creator>kgaut</dc:creator>
    <guid isPermaLink="false">node/470</guid>
    </item>
<item>
  <title>Drupal changer le titre d'une page Views (vues)</title>
  <link>https://kgaut.net/snippets/2026/drupal-changer-le-titre-dune-page-views-vues?pk_campaign=rss</link>
  <description>&lt;p&gt;Le titre d'une page de vue se gère normalement directement dans la vue, mais il es possible de changer le changer dynamiquement si nécessaire.&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;/**
 * Implements hook_views_post_render().
 */
function MON_MODULE_views_post_render(Drupal\views\ViewExecutable $view) {
  if ($view-&amp;gt;element['#view_id'] === 'ma_vue') {
    if ($view-&amp;gt;element['#display_id'] === 'page_1') {
      $title = "Mon titre dynamique;
      $view-&amp;gt;setTitle($title);
      $route = \Drupal::routeMatch()-&amp;gt;getCurrentRouteMatch()-&amp;gt;getRouteObject();
      $route-&amp;gt;setDefault('_title_callback', function() use ($title) {
        return $title; 
      });
      }
    }
  }&lt;/code&gt;&lt;/pre&gt;</description>
  <pubDate>Fri, 27 Mar 2026 09:26:12 +0100</pubDate>
    <dc:creator>kgaut</dc:creator>
    <guid isPermaLink="false">node/469</guid>
    </item>
<item>
  <title>Drupal - Ajouter Scheduler à un type d'entité custom</title>
  <link>https://kgaut.net/snippets/2026/drupal-ajouter-scheduler-un-type-dentite-custom?pk_campaign=rss</link>
  <description>&lt;p&gt;Scheduler est un module drupal qui permet, pour un contenu, de définir une date de publication et/ou une date de dépublication.&lt;/p&gt;&lt;p&gt;Par défaut, il ne s'interface qu'avec les noeuds (Node), mais depuis la version 2.x il est possible de l'interfacer avec des type entités custom, attention : uniquement si elle possède des bundle, cela ne fonctionnera pas pour un type d'entité sans bundle. (Issue drupal sur le sujet : https://www.drupal.org/project/scheduler/issues/3355087)&lt;/p&gt;&lt;p&gt;Si c'est le cas, alors il est très facile de définir que votre type d'entité doit être « schedulable », il suffit de créer un plugin dans le dossier src/Plugin/Scheduler de votre module.&lt;/p&gt;&lt;p&gt;Dans mon cas mon type d'entité s'appelle Annonce (id : annonce) j'ai donc créé la classe /modules/custom/mon_module/src/Plugin/Scheduler/AnnonceScheduler.php avec le contenu suivant :&amp;nbsp;&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;&amp;lt;?php

namespace Drupal\mon_module\Plugin\Scheduler;

use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\scheduler\SchedulerPluginBase;

/**
 * Plugin for Annonce entity type.
 *
 * @package Drupal\Scheduler\Plugin\Scheduler
 *
 * @SchedulerPlugin(
 *  id = "annonce_scheduler",
 *  label = @Translation("Annonce Scheduler Plugin"),
 *  description = @Translation("Provides support for scheduling Annonce entities"),
 *  entityType = "annonce",
 *  dependency = "mon_module",
 * )
 */
class AnnonceScheduler extends SchedulerPluginBase implements ContainerFactoryPluginInterface {}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Videz votre cache, et vous pourrez alors modifier les paramètres directement sur la configuration du bundle de votre type d'entité :&amp;nbsp;&lt;/p&gt;
  
      
  
    Image
                &lt;img loading="lazy" src="https://kgaut.net/sites/kgaut/files/inline-images/2026-02-1816-42-43png.png" width="945" height="1087" alt="Drupal scheduler"&gt;


          

  
&lt;p&gt;&amp;nbsp;&lt;/p&gt;</description>
  <pubDate>Wed, 18 Feb 2026 16:35:28 +0100</pubDate>
    <dc:creator>kgaut</dc:creator>
    <guid isPermaLink="false">node/468</guid>
    </item>
<item>
  <title>Drupal - Créer des entrées custom pour XML Sitemap via le code</title>
  <link>https://kgaut.net/snippets/2026/drupal-creer-des-entrees-custom-pour-xml-sitemap-le-code?pk_campaign=rss</link>
  <description>&lt;p&gt;Si vous utilisez le module drupal xmlsitemap, il est possible d'ajouter des liens personnalisés vers des controleurs ou des vues via le sous module xmlsitemap_custom et l'UI accessible à l'uri : /admin/config/search/xmlsitemap/custom/add :&amp;nbsp;&lt;/p&gt;
  
      
  
    Image
                &lt;img loading="lazy" src="https://kgaut.net/sites/kgaut/files/inline-images/2026-01-0516-15-21png.png" width="719" height="651" alt="Drupal xml sitemap custom"&gt;


          

  
&lt;p&gt;Du coup ces « custom links » ne seront pas en config, donc non déployable.&amp;nbsp;&lt;/p&gt;&lt;p&gt;Voici comment en créer via le code :&amp;nbsp;&lt;/p&gt;&lt;p&gt;mon_module.post_update.php&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;/**
 * Create Custom XML Sitemap Entries.
 */
function mon_module_post_update_create_xmlsitemap_custom_entries() {
  $entries = [
    [
      'loc' =&amp;gt; '/ma-page-fr',
      'language' =&amp;gt; 'fr',
    ],
    [
      'loc' =&amp;gt; '/ma-page-en',
      'language' =&amp;gt; 'en',
    ],
    [
      'loc' =&amp;gt; '/mon-autre-page-fr',
      'language' =&amp;gt; 'fr',
    ],
  ];
  /** @var \Drupal\xmlsitemap\XmlSitemapLinkStorageInterface $linkStorage */
  $linkStorage = \Drupal::service('xmlsitemap.link_storage');
  $query = \Drupal::database()-&amp;gt;select('xmlsitemap', 'x');
  $query-&amp;gt;addExpression("MAX(CAST(id AS INT))");
  $query-&amp;gt;condition('type', 'custom');
  $id = (int) $query-&amp;gt;execute()-&amp;gt;fetchField();
  foreach ($entries as $entry) {
    $entry += [
      'id' =&amp;gt; ++$id,
      'type' =&amp;gt; 'custom',
      'subtype' =&amp;gt; '',
      'priority' =&amp;gt; 0.7,
      'changefreq' =&amp;gt; XMLSITEMAP_FREQUENCY_DAILY,
    ];
    $linkStorage-&amp;gt;save($entry);
  }

}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;</description>
  <pubDate>Mon, 05 Jan 2026 16:13:54 +0100</pubDate>
    <dc:creator>kgaut</dc:creator>
    <guid isPermaLink="false">node/467</guid>
    </item>
<item>
  <title>Drupal - Un petit filtre pour les commentaires en fonction d'une liste de mots bannis</title>
  <link>https://kgaut.net/snippets/2025/drupal-un-petit-filtre-pour-les-commentaires-en-fonction-dune-liste-de-mots-bannis?pk_campaign=rss</link>
  <description>&lt;p&gt;C'est loin d'être la solution la plus moderne au monde, mais voila un petit filtre qui, en fonction d'une liste de mots publiera ou non un commentaire posté sur votre site.&amp;nbsp;&lt;/p&gt;&lt;p&gt;Évidement l'idée peut s'adapter à toute sorte de formulaire de votre site.&lt;/p&gt;&lt;p&gt;J'ai utilisé le hook comment_presave :&amp;nbsp;&lt;/p&gt;&lt;p&gt;Fichier : web/modules/mon_module/src/Hook/EntityHooks.php&amp;nbsp;&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;&amp;lt;?php

namespace Drupal\mon_module\Hook;

use Drupal\comment\CommentInterface;
use Drupal\Core\DependencyInjection\AutowireTrait;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Hook\Attribute\Hook;

/**
 * Provides hook implementations for Entity alterations.
 */
final class EntityHooks implements ContainerInjectionInterface {

  use AutowireTrait;

  /**
   * Comments blacklisted words.
   */
  protected static array $commentBlackListWords = [
    "useful",
    "nexus",
    "д",
    "Psilocybin",
    "?link",
    "url?",
    "dark market?",
    "dark web",
    "darkweb",
    "darknet",
    "Gambling",
    "darkmarket",
    "drug",
    "Russia",
    "อ",
    "ส์",
    "Why SQL Matters in Business Analytic",
  ];

  /**
   * Hook method triggered before a comment is saved.
   *
   * Checks the comment body for blacklisted words and updates the comment
   * status to "not published" if any blacklisted word is detected.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The comment entity being processed.
   */
  #[Hook('comment_presave')]
  public function commentPresave(EntityInterface $entity): void {
    if ($entity instanceof CommentInterface) {
      $body = $entity-&amp;gt;get('comment_body')-&amp;gt;value;
      $body = strtolower($body);
      foreach (self::$commentBlackListWords as $word) {
        if (str_contains($body, strtolower($word))) {
          $entity-&amp;gt;set('status', CommentInterface::NOT_PUBLISHED);
        }
      }
    }
  }

}
&lt;/code&gt;&lt;/pre&gt;</description>
  <pubDate>Fri, 05 Dec 2025 09:19:56 +0100</pubDate>
    <dc:creator>kgaut</dc:creator>
    <guid isPermaLink="false">node/466</guid>
    </item>
<item>
  <title>Drupal - Comment surcharger vos nodes avec les classes de bundle</title>
  <link>https://kgaut.net/blog/2025/drupal-comment-surcharger-vos-nodes-avec-les-classes-de-bundle?pk_campaign=rss</link>
  <description>&lt;p&gt;Cela n'est pas franchement une nouveauté, les Classes de Bundle (Bundle Classes) ont été introduites dans le core dès Drupal 9.3 en 2021. Pourtant, je vois encore beaucoup de projets qui s'en passent, et cela fait longtemps que je voulais aborder le sujet ici.&lt;/p&gt;&lt;p&gt;C'est une fonctionnalité qui change la donne en termes d'architecture et de maintenabilité. Aujourd'hui, nous allons voir comment les mettre en place, en profitant de l'occasion pour utiliser la nouvelle syntaxe de hooks orientée objet disponible depuis Drupal 11 (voir à ce sujet : https://kgaut.net/blog/2024/drupal-11-utiliser-la-nouvelle-syntaxe-orientee-objet-pour-les-hooks)&lt;/p&gt;Pourquoi les classes de bundle ?&lt;p&gt;De tout temps dans Drupal, le contenu est principalement représenté par des nœuds. Techniquement, un nœud de type "Article" et un nœud de type "Concert" sont tous deux des instances de la même classe générique : &lt;code&gt;Drupal\node\Entity\Node&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;Depuis Drupal 8, il est possible de surcharger cette classe pour y ajouter une couche métier. Imaginons que vous ayez un type de contenu "Concert" avec un champ date. Vous pourriez vouloir une méthode &lt;code&gt;getDateConcert()&lt;/code&gt; pour récupérer cette date formatée facilement.&lt;/p&gt;&lt;p&gt;Le problème, c'est que si vous surchargez la classe &lt;code&gt;Node&lt;/code&gt; générique, cette méthode &lt;code&gt;getDateConcert()&lt;/code&gt; se retrouve disponible sur tous vos nœuds, y compris les articles de blog ou les pages statiques, ce qui n'a aucun sens sémantiquement.&lt;/p&gt;&lt;p&gt;C'est là qu'interviennent les Classes de Bundle. Elles permettent de corriger ce défaut en définissant une classe PHP spécifique pour un bundle précis.&lt;/p&gt;Exemple d'utilisation : Concerts et Articles&lt;p&gt;Prenons un exemple concret. Dans un module personnalisé mon_module, nous voulons gérer deux types de contenus :&lt;/p&gt;&lt;p&gt;&amp;nbsp; &amp;nbsp;Concert (concert) : Nous voulons une méthode pour récupérer facilement sa date.&lt;/p&gt;&lt;p&gt;&amp;nbsp; &amp;nbsp;Article (article) : Nous voulons juste qu'il ait sa propre classe pour de futurs besoins.&lt;/p&gt;Étape 1 : Créer les classes&lt;p&gt;Pour faire les choses proprement et avoir un socle solide, je commence par définir une classe abstraite. Elle servira de base à toutes mes classes de bundle de nœuds et contiendra les méthodes communes si besoin.&lt;/p&gt;&lt;p&gt;Fichier : mon_module/src/Entity/AbstractNode.php&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;&amp;lt;?php

namespace Drupal\mon_module\Entity;

use Drupal\node\Entity\Node;

/**
 * Classe abstraite de base pour nos classes de bundle.
 */
abstract class AbstractNode extends Node {
    // On pourra ajouter ici des méthodes utiles à tous nos types de contenus.
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Ensuite, je crée la classe spécifique pour mon type de contenu "Concert". C'est ici que j'ajoute ma logique métier.&lt;/p&gt;&lt;p&gt;Fichier : mon_module/src/Entity/ConcertNode.php&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;&amp;lt;?php

namespace Drupal\mon_module\Entity;

use Drupal\Core\Datetime\DrupalDateTime;

/**
 * Classe spécifique pour le bundle 'concert'.
 */
class ConcertNode extends AbstractNode {

  /**
   * Retourne la date du concert sous forme d'objet DrupalDateTime.
   *
   * @return \Drupal\Core\Datetime\DrupalDateTime|null
   * La date du concert ou null si le champ est vide.
   * @throws \Drupal\Core\TypedData\Exception\MissingDataException
   */
  public function getDate(): ?DrupalDateTime {
    if ($this-&amp;gt;hasField('field_date') &amp;amp;&amp;amp; !$this-&amp;gt;get('field_date')-&amp;gt;isEmpty()) {
        return $this-&amp;gt;get('field_date')-&amp;gt;date;
    }
    return null;
  }

  /**
   * Une méthode helper pour savoir si le concert est passé.
   */
  public function isPast(): bool {
      $date = $this-&amp;gt;getDate();
      if ($date) {
          return $date-&amp;gt;getTimestamp() &amp;lt; \Drupal::time()-&amp;gt;getRequestTime();
      }
      return FALSE;
  }

}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Je crée aussi rapidement celle pour l'article, même si elle est vide pour l'instant :&amp;nbsp;&lt;/p&gt;&lt;p&gt;Fichier : mon_module/src/Entity/ArticleNode.php&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;&amp;lt;?php

namespace Drupal\mon_module\Entity;

class ArticleNode extends AbstractNode {}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;Étape 2 : Déclarer les classes (Nouvelle méthode Drupal 11)&lt;p&gt;Maintenant, il faut dire à Drupal : "Quand tu charges un nœud de type 'concert', utilise ma classe ConcertNode au lieu de la classe par défaut".&lt;/p&gt;&lt;p&gt;Cela se fait via le hook entity_bundle_info_alter. Utilisons la syntaxe moderne avec les attributs PHP dans une classe dédiée aux hooks :&lt;/p&gt;&lt;p&gt;Fichier : mon_module/src/Hook/EntityHooks.php&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;&amp;lt;?php

namespace Drupal\mon_module\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\mon_module\Entity\ArticleNode;
use Drupal\mon_module\Entity\ConcertNode;

/**
 * Fournit les implémentations de hooks relatives aux entités.
 */
final class EntityHooks {

  /**
   * Implements hook_entity_bundle_info_alter().
   *
   * Modifie la définition des bundles pour associer nos classes personnalisées.
   */
  #[Hook('entity_bundle_info_alter')]
  public function bundleInfoAlter(array &amp;amp;$bundles): void {
    // Mapping pour le type de contenu 'concert'
    if (isset($bundles['node']['concert'])) {
      $bundles['node']['concert']['class'] = ConcertNode::class;
    }
    // Mapping pour le type de contenu 'article'
    if (isset($bundles['node']['article'])) {
      $bundles['node']['article']['class'] = ArticleNode::class;
    }
  }

}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;N'oubliez pas de vider les caches pour que Drupal prenne en compte le nouveau hook et les nouvelles classes !&lt;/p&gt;Le résultat : une utilisation simplifiée&lt;p&gt;C'est là que la magie opère. Vous n'avez rien à changer dans votre manière de charger des entités. Drupal instanciera automatiquement la bonne classe.&lt;/p&gt;Dans du code PHP (un contrôleur, un service...) :&lt;pre&gt;&lt;code class="language-php"&gt;use Drupal\node\Entity\Node;
use Drupal\mon_module\Entity\ConcertNode;

$nid = 25; // Imaginons que le nœud 25 est un concert.
$entity = Node::load($nid);

// PHP sait maintenant que $entity est une instance de ConcertNode.
if ($entity instanceof ConcertNode) {
    // On peut appeler nos méthodes personnalisées directement.
    $date = $entity-&amp;gt;getDate();
    
    if ($entity-&amp;gt;isPast()) {
        // Logique spécifique si le concert est passé...
    }
}&lt;/code&gt;&lt;/pre&gt;Dans Twig&lt;p&gt;C'est encore plus agréable pour les développeurs front-end. Grâce à la magie des getters dans Twig, &lt;code&gt;getDate()&lt;/code&gt; devient accessible via &lt;code&gt;node.date&lt;/code&gt; (si le nom ne rentre pas en conflit avec un champ) ou en appelant la méthode directement.&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;{# Dans node--concert.html.twig #}

&amp;lt;article class="concert {{ node.isPast ? 'concert--past' : 'concert--upcoming' }}"&amp;gt;
  &amp;lt;h2&amp;gt;{{ label }}&amp;lt;/h2&amp;gt;
  {# Appel direct de la méthode #}
  &amp;lt;p&amp;gt;Date du concert : {{ node.getDate().format('d/m/Y') }}&amp;lt;/p&amp;gt;
  
  {# Utilisation de la méthode booléenne #}
  {% if node.isPast() %}
    &amp;lt;span class="badge"&amp;gt;Concert terminé&amp;lt;/span&amp;gt;
  {% endif %}
&amp;lt;/article&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Fini les &lt;code&gt;preprocess&lt;/code&gt; compliqués pour calculer des états ou formater des dates spécifiques !&lt;/p&gt;Au-delà des Nœuds&lt;p&gt;L'exemple ici porte sur les nœuds, mais cela s'applique évidemment à l'ensemble des types d'entités de contenu : Utilisateurs, Termes de taxonomie, Media, etc.&lt;/p&gt;&lt;p&gt;Dans votre hook, vous pourriez ajouter :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;// Dans EntityHooks::bundleInfoAlter

// Pour un vocabulaire 'tags'
if (isset($bundles['taxonomy_term']['tags'])) {
  $bundles['taxonomy_term']['tags']['class'] = TagTerm::class;
}
// Pour les utilisateurs (le bundle s'appelle aussi 'user')
if (isset($bundles['user']['user'])) {
  $bundles['user']['user']['class'] = UserCustom::class;
}&lt;/code&gt;&lt;/pre&gt;Conclusion&lt;p&gt;À titre personnel, je trouve cette fonctionnalité indispensable sur tout projet Drupal moderne. Cela permet d'ajouter toute une couche métier (getters, setters, méthodes utilitaires) directement là où elle doit être : dans l'objet qui représente la donnée.&lt;/p&gt;&lt;p&gt;Le code devient beaucoup plus propre, orienté objet, plus facile à tester unitairement, et bien plus maintenable sur le long terme. Si vous ne les utilisez pas encore, foncez !&lt;/p&gt;&lt;p&gt;Voir la présentation sur drupal.org : https://www.drupal.org/node/3191609&lt;/p&gt;</description>
  <pubDate>Fri, 05 Dec 2025 08:01:52 +0100</pubDate>
    <dc:creator>kgaut</dc:creator>
    <guid isPermaLink="false">node/465</guid>
    </item>
<item>
  <title>/now édito de décembre 2025</title>
  <link>https://kgaut.net/blog/2025/now-edito-de-decembre-2025?pk_campaign=rss</link>
  <description>&lt;p data-path-to-node="14"&gt;La fin de l'année se profile déjà. Ce fut encore une belle année sur le plan pro, riche en projets intéressants.&lt;/p&gt;&lt;p data-path-to-node="14"&gt;Cette année aura marqué mes 10 ans de freelance à temps plein ainsi que les 10 ans de mon déménagement depuis les montagnes de Haute-Savoie vers les volcans du Puy-de-Dôme.&lt;/p&gt;&lt;p data-path-to-node="15"&gt;Fait marquant de l'année : depuis 2021, je travaillais pour le groupe Fournier (Perene, Mobalpa, SoCoo'c, Hygena et Delpha). D'abord développeur back Drupal, j'ai ensuite évolué vers un rôle transverse, à la croisée du développement et de l'infrastructure. J'ai notamment piloté la migration d'un hébergeur « classique » vers le Cloud (GCP). Cela a impliqué une grosse dose de Docker pour conteneuriser les sites et applications, de la CI avec GitLab pour orchestrer des pipelines de déploiement aux petits oignons, et une multitude d'optimisations pour simplifier la vie des développeurs. Cette mission s'est achevée fin octobre.&lt;/p&gt;&lt;p data-path-to-node="16"&gt;Cela a laissé un petit vide, mais ça fait du bien de souffler et de s'attaquer aux sujets qui traînaient : quelques sites pour des associations dont je suis membre et la refonte de mon serveur auto-hébergé. J'en profite d'ailleurs pour tout documenter. Avec Pierre Lecourt (minimachines.net), nous préparons une série de guides pour monter son « Home Server », de l'OS jusqu'au reverse proxy et Docker. Le premier guide devrait sortir rapidement !&lt;/p&gt;&lt;p data-path-to-node="17"&gt;Côté enseignement, après plus de dix ans en BUT INFO à Annecy-le-Vieux, j'ai fait une pause cette année. Les allers-retours devenaient trop fatigants, mais l'envie de transmettre est toujours là. Je reprendrai sûrement si je trouve une opportunité plus proche de chez moi.&lt;/p&gt;&lt;p data-path-to-node="18"&gt;Enfin, j'avais l'ambition de poster davantage ici (bilan mitigé). Je suis lassé de voir tant de contenu de qualité offert à des plateformes tierces instables ou rachetées par des mégalomanes. Pour moi, Internet, ce n'est pas ça. Le Web 2.0 ne nous laissera rien. Rendez-nous nos forums phpBB et nos blogs ; gardez vos posts LinkedIn générés par IA et vides de sens (mais rendez-nous Grégory Logan, au moins c'était drôle).&lt;/p&gt;</description>
  <pubDate>Wed, 03 Dec 2025 20:47:06 +0100</pubDate>
    <dc:creator>kgaut</dc:creator>
    <guid isPermaLink="false">node/464</guid>
    </item>
<item>
  <title>Drupal - Les « menu deriver » pour générer des éléments de menu dynamiquement</title>
  <link>https://kgaut.net/snippets/2025/drupal-les-menu-deriver-pour-generer-des-elements-de-menu-dynamiquement?pk_campaign=rss</link>
  <description>&lt;p&gt;Dans Drupal, les éléments de menu sont gérés soit via l'interface d'administration soit dans les fichiers mon_module.links.menu.yml, de la manière suivante :&amp;nbsp;&lt;/p&gt;&lt;p&gt;Un lien vers un noeud :&amp;nbsp;&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;enfarandole.main.help:
  title: 'Nous aider'
  weight: 2
  route_name: entity.node.canonical
  route_parameters:
    node: 5
  menu_name: main&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Un lien vers une vue :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;amicale.main.events:
  title: 'Évènements'
  weight: 1
  route_name: view.evenements.listing
  menu_name: main&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Mais il est aussi possible d'utiliser un deriver, pour créer des éléments de menu de manière dynamique, dans l'exemple suivant je vais lister dans un sous menu tous les contenus d'un certains type, je commence par la déclaration de mon menu parent et de mon deriver, toujours dans le fichier mon_module.links.menu.yml :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;# Mon élément parent, sans lien, qui sera dans le menu principal
amicale.main.sections:
  title: 'Sections'
  weight: 1
  route_name: '&amp;lt;nolink&amp;gt;'
  menu_name: main

# La définition de mon dériver pointant vers une classe
amicale.main.sections.section: 
  deriver: '\Drupal\mon_module\Plugin\Derivative\SectionMenuLinkDeriver'
  class: '\Drupal\Core\Menu\MenuLinkDefault'&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;La classe de mon dériver (web/modules/mon_module/src/Plugin/Derivative/SectionMenuLinkDeriver.php)&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;&amp;lt;?php

namespace Drupal\mon_module\Plugin\Derivative;

use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Menu deriver for content type « page_de_section ».
 */
final class SectionMenuLinkDeriver extends DeriverBase implements ContainerDeriverInterface {

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManager
   */
  protected EntityTypeManager $entityTypeManager;

  /**
   * CategoryMenuLinkDeriver constructor.
   *
   * @param \Drupal\Core\Entity\EntityTypeManager $entityTypeManager
   *   The entity type manager.
   */
  public function __construct(EntityTypeManager $entityTypeManager) {
    $this-&amp;gt;entityTypeManager = $entityTypeManager;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, $base_plugin_id) {
    return new static($container-&amp;gt;get('entity_type.manager'));
  }

  /**
   * {@inheritdoc}
   */
  public function getDerivativeDefinitions($base_plugin_definition) {
    $links = [];

    $storage = $this-&amp;gt;entityTypeManager-&amp;gt;getStorage('node');

    $query = $storage-&amp;gt;getQuery();
    // Je charge tous les noeud du type « page_de_section »
    $query-&amp;gt;accessCheck(TRUE);
    $query-&amp;gt;condition('type', 'page_de_section');
    $query-&amp;gt;sort('title');
    $sections = $query-&amp;gt;execute();

    // Et pour chaque, je vais créer un élément de menu
    foreach ($sections as $section) {
      if (!$section = $storage-&amp;gt;load($section)) {
        continue;
      }

      // Create menu ID.
      $menuId = 'amicale.main.sections.section:' . $section-&amp;gt;id();

      $link = [
        'id' =&amp;gt; $menuId,
        'route_name' =&amp;gt; entity.node.canonical,
        'route_parameters' =&amp;gt; ['node' =&amp;gt; $section-&amp;gt;id()],
        'title' =&amp;gt; $section-&amp;gt;label(), // Le titre de mon élément de menu (ici le titre du noeud)
        'parent' =&amp;gt; 'amicale.main.sections', // je définis ici le menu item parent, si omis, ça sera un élément de menu de niveau 1
      ] + $base_plugin_definition;
      
      $links[$menuId] = $link;
    }

    return $links;
  }

}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On vide son cache, et voila !&lt;/p&gt;&lt;p&gt;Évidement tout est possible au niveau de ce que l'on veut ajouter comme élément de menu, catégories basée sur un vocabulaire, liste de membres...&lt;/p&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;</description>
  <pubDate>Wed, 03 Dec 2025 20:04:49 +0100</pubDate>
    <dc:creator>kgaut</dc:creator>
    <guid isPermaLink="false">node/463</guid>
    </item>
<item>
  <title>Drupal - Utiliser le Lazy Builder pour charger un élément de manière asynchrone</title>
  <link>https://kgaut.net/snippets/2025/drupal-utiliser-le-lazy-builder-pour-charger-un-element-de-maniere-asynchrone?pk_campaign=rss</link>
  <description>&lt;p&gt;Dans Drupal Le lazybuilder permet permet de charger une partie d'une page (bloc, sous-partie, champ...) de manière asynchrone ce qui permet d'optimiser le chargement de la page ou de nous abstraire de certaines problématiques posées par le cache.&lt;/p&gt;&lt;p&gt;Voici comment la mettre en oeuvre :&amp;nbsp;&lt;/p&gt;&lt;p&gt;Déclaration du placehoder :&amp;nbsp;&lt;/p&gt;&lt;p&gt;Un renderable array, qui sera subsitiué par le contenu de notre lazy builder :&amp;nbsp;&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;$variables['content']['field_coupons'] = [
  '#create_placeholder' =&amp;gt; TRUE,
  '#lazy_builder' =&amp;gt; [
    'mon_module.coupon.lazy_builder:renderCoupon', [$coupon-&amp;gt;id(), $node-&amp;gt;id(), $node-&amp;gt;language()-&amp;gt;getId()],
  ],
];&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Je déclare donc une zone qui sera remplacée par ce que me retourne la méthode renderCoupon du service : mon_module.coupon.lazy_builder.&lt;/p&gt;&lt;p&gt;Déclaration du service&lt;/p&gt;&lt;p&gt;mon_module.services.yml&lt;/p&gt;&lt;pre&gt;&lt;code class="language-yaml"&gt;services:
  mon_module.coupon.lazy_builder:
    class: Drupal\mon_module\LazyBuilder\CouponLazyBuilder
    tags:
      - { name: lazy_builder }&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;l'implémentation du service&lt;/p&gt;&lt;p&gt;mon_module/src/LazyBuilder/CouponLazyBuilder.php&lt;/p&gt;&lt;pre&gt;&lt;code class="language-php"&gt;&amp;lt;?php

namespace Drupal\clearblue\LazyBuilder;

use Drupal\clearblue\Entity\Node;
use Drupal\Core\Security\TrustedCallbackInterface;

/**
 * Class LastViewedContentLazyBuilder
 * To render the last viewed content using Lazy Builder.
 *
 * @package Drupal\your_custom_module
 */
class CouponLazyBuilder implements TrustedCallbackInterface {

  /**
   * {@inheritdoc}
   */
  public static function trustedCallbacks(): array {
    return ['renderCoupon']; // je déclare ici explicitement les méthodes autorisées à être appelées en ajax
  }

  public function renderCoupon($couponId, $nodeId, $langcode): array {
    $node = Node::load($nodeId);
    
    // Ici je retourne l'affichage du champ d'un noeud, mais on peut évidement faire ce que l'on veut.
    $renderableArray = \Drupal::entityTypeManager()-&amp;gt;getViewBuilder('node')-&amp;gt;viewField($node-&amp;gt;get('field_coupons'), [
      'type' =&amp;gt; 'entity_reference_entity_view',
      'label' =&amp;gt; 'hidden',
      'settings' =&amp;gt; [
        'view_mode' =&amp;gt; 'default',
        'link' =&amp;gt; 'true',
      ],
    ]);

    $renderableArray['#cache']['tags'] = [
      'node:' . $nodeId,
      'langcode:' . $langcode,
      'coupons:' . $couponId,
    ];
    
    return $renderableArray;

  }

}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;</description>
  <pubDate>Wed, 12 Nov 2025 16:00:33 +0100</pubDate>
    <dc:creator>kgaut</dc:creator>
    <guid isPermaLink="false">node/462</guid>
    </item>
<item>
  <title>Migrer une instance gitlab sur un nouveau serveur</title>
  <link>https://kgaut.net/blog/2025/migrer-une-instance-gitlab-sur-un-nouveau-serveur?pk_campaign=rss</link>
  <description>&lt;p&gt;Avec la fin de vie de Centos, j'ai du migrer mon installation gitlab sur un nouveau serveur. J'ai choisi pour cela la distribution Almalinux 9 (https://almalinux.org/), basée aussi sur RedHat, avec le même système de paquets, je ne serai pas perdu.&lt;/p&gt;&lt;p&gt;Cette procédure doit fonctionner avec toutes les distributions supportées par gitlab (https://docs.gitlab.com/install/package/#supported-platforms), modulo les adaptations dans certaines commandes (apt install au lieu de dnf install si vous êtes sur une base debian par exemple).&lt;/p&gt;&lt;p&gt;J'ai pris comme référence la documentation suivante : https://docs.gitlab.com/administration/backup_restore/restore_gitlab/&lt;/p&gt;&lt;p&gt;À noter, il faudra impérativement installer sur le nouveau serveur une version de gitlab identique à celle de l'ancien serveur, pour la trouver, rendez-vous sur le pannel d'administration et vous trouverez l'information en bas à droite :&amp;nbsp;&lt;/p&gt;
  
      
  
    Image
                &lt;img loading="lazy" src="https://kgaut.net/sites/kgaut/files/inline-images/gitlab/gitlab-serveurpng.png" width="443" height="342" alt="gitlab version serveur"&gt;


          

  
&lt;p&gt;Il vous faudra donc un accès ssh à votre ancien serveur (dans la suite l'ip sera substituée par ANCIEN), un accès ssh sur le nouveau serveur (dans la suite l'ip sera substituée par NOUVEAU), Le nouveau serveur est dans mon cas une instance où est fraîchement installé un AlmaLinux 9, et rien d'autre.&lt;/p&gt;Installation de gitlab sur le nouveau serveur&lt;p&gt;Connectez-vous en ssh à votre nouveau serveur (ssh root@NOUVEAU)&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;# Mises à jour et installation de paquets nécessaires
dnf update 
dnf install -y curl vim

# Installation des dépots Gitlab Community Edition
curl "https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.rpm.sh" | sudo bash

# Trouver la version du paquet à installer correspondante à la version installée sur l'ancien serveur  

dnf search --showduplicates gitlab-ce

# dans mon cas : gitlab-ce-17.7.7-ce.0.el9

# Installation de gitlab en spécifiant l'url de l'instance
sudo EXTERNAL_URL="https://gitlab.exemple.com" dnf install gitlab-ce-17.7.7-ce.0.el9&lt;/code&gt;&lt;/pre&gt;Backup et transfert de l'instance existante&lt;p&gt;Connectez-vous en ssh à votre ancien serveur (ssh root@NOUVEAU)&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;# Si vous n'avez pas de paire de clé ssh sur votre ancien serveur, créez-en une :
ssh-keygen -t ed25519 -C "root@ancien"

# on ajoute la clée public de l'ancien serveur au nouveau afin de pouvoir s'y connecter sans mot de passe
ssh-copy-id root@NOUVEAU


# Avant de lancer le backup, on désactive l'accès à l'instance
sudo gitlab-ctl stop sidekiq
sudo gitlab-ctl stop puma

# On lance un backup de son instance gitlab
gitlab-backup create BACKUP=dump_full GZIP_RSYNCABLE=yes

# Transferts des secrets (fichier non présent dans le backup)
rsync /etc/gitlab/gitlab-secrets.json -e ssh root@NOUVEAU:/etc/gitlab/

# Transferts de la configuration (fichier non présent dans le backup)
rsync /etc/gitlab/gitlab.rb -e ssh root@NOUVEAU:/etc/gitlab/

# Transfert des backups 
rsync -avh --stats /var/opt/gitlab/backups/* -e ssh root@NOUVEAU:/var/opt/gitlab/backups/&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Restauration de la sauvegarde sur le nouveau serveur&lt;/p&gt;&lt;p&gt;De nouveau connecté ssh à votre nouveau serveur (ssh root@NOUVEAU)&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;# On arrête les services connectés à la base de données
sudo gitlab-ctl stop puma
sudo gitlab-ctl stop sidekiq

# on regarde le nom du backup à restaurer : 
ls -alh /var/opt/gitlab/backups/  

# dans mon cas l'archive se nomme dump_full
# on ne prend pas en compte la partie du fichier « _gitlab_backup.tar »
# -rw-------. &amp;nbsp;1 git &amp;nbsp;git &amp;nbsp;&amp;nbsp;22G Oct 24 08:48 dump_full_gitlab_backup.tar  

# on lance la restauration (processus long)
sudo gitlab-backup restore BACKUP=dump_full

# on lance une reconfiguration de gitlab à partir des données restaurées
sudo gitlab-ctl reconfigure

# on relance tous les services
sudo gitlab-ctl start

# On vérifie que tout va bien
sudo gitlab-rake gitlab:check SANITIZE=true

# On vérifie l'intégritées des données chiffrées à partir du fichier secrets que l'on a transféré
sudo gitlab-rake gitlab:doctor:secrets

# On vérifie l'intégritée globale de l'ensemble des données restaurées
sudo gitlab-rake gitlab:artifacts:check
sudo gitlab-rake gitlab:lfs:check
sudo gitlab-rake gitlab:uploads:check

# On lance une analyse de la base de données via la console 
sudo gitlab-rails dbconsole --database main

SET STATEMENT_TIMEOUT=0 ; ANALYZE VERBOSE;&lt;/code&gt;&lt;/pre&gt;Fin de la migration&lt;p&gt;Si tout va bien, vous pouvez maintenant faire pointer soit changer la configuration dns pour pointer le nom de domaine de votre serveur gitlab vers le nouveau serveur ou alors affecter l'ip de l'ancien serveur au nouveau (ce que j'ai fait, étant sur des serveurs virtualisés).&lt;/p&gt;&lt;p&gt;Une fois &amp;nbsp;que tout est ok, vous pourrez lancer les mises à jours de gitlab sur le nouveau serveur afin de profiter des dernières mise à jour :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;sudo dnf update&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Dans mon cas, j'ai du faire des mises à jour intermédiaires, 17.8.x, 17.11.x puis 18.2.x avant de lancer la mise à jour finale:&amp;nbsp;&lt;/p&gt;&lt;p&gt;Pour trouver la dernière version d'une version intermédiaire :&amp;nbsp;&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;dnf search --showduplicates gitlab-ce | grep "17.8"
Last metadata expiration check: 0:03:51 ago on Fri 24 Oct 2025 01:06:17 PM UTC.
gitlab-ce-17.8.0-ce.0.el9.x86_64 : GitLab Community Edition (including NGINX, Postgres, Redis)
gitlab-ce-17.8.1-ce.0.el9.x86_64 : GitLab Community Edition (including NGINX, Postgres, Redis)
gitlab-ce-17.8.2-ce.0.el9.x86_64 : GitLab Community Edition (including NGINX, Postgres, Redis)
gitlab-ce-17.8.4-ce.0.el9.x86_64 : GitLab Community Edition (including NGINX, Postgres, Redis)
gitlab-ce-17.8.5-ce.0.el9.x86_64 : GitLab Community Edition (including NGINX, Postgres, Redis)
gitlab-ce-17.8.6-ce.0.el9.x86_64 : GitLab Community Edition (including NGINX, Postgres, Redis)
gitlab-ce-17.8.7-ce.0.el9.x86_64 : GitLab Community Edition (including NGINX, Postgres, Redis)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;j'ai donc lancé successivement :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;sudo dnf instal gitlab-ce-17.8.7-ce.0.el9.x86_64

sudo dnf install gitlab-ce-17.11.7-ce.0.el9.x86_64

sudo dnf install gitlab-ce-18.2.8-ce.0.el9.x86_64&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Pour terminer, pensez-bien à réinstaller gitlab runner si vous utilisiez la CI (https://docs.gitlab.com/runner/install/linux-repository/) et remettre en place la sauvegarde de votre instance. Voilà ce que j'ai dans mon crontab pour 2 sauvegarder par jour + une sauvegarde complète :&lt;/p&gt;&lt;pre&gt;&lt;code class="language-bash"&gt;30 18 * * * gitlab-backup create BACKUP=dump_night GZIP_RSYNCABLE=yes SKIP=registry,artifacts  
30 11 * * * gitlab-backup create BACKUP=dump_day GZIP_RSYNCABLE=yes SKIP=registry,artifacts  
30 19 5 * * gitlab-backup create BACKUP=dump_full GZIP_RSYNCABLE=yes  &lt;/code&gt;&lt;/pre&gt;</description>
  <pubDate>Mon, 27 Oct 2025 08:08:44 +0100</pubDate>
    <dc:creator>kgaut</dc:creator>
    <guid isPermaLink="false">node/460</guid>
    </item>

  </channel>
</rss>
