Les points clés de ce retour d'expérience :
- Explicitation du contrat de données : Remplacement des tableaux associatifs implicites (array $email et array $aVariable) par des objets dédiés portant la structure des données du pipeline.
- Clarification des signatures de services : Simplification de la méthode d'envoi newEmail(EmailVo $email) qui ne reçoit plus de paramètres obsolètes ou de tableaux hétérogènes.
- Centralisation du contexte dans EmailVo : Regroupement du destinataire, des clés de template, du contenu compilé, des liens et du journal d'envoi dans une API d'accès claire.
- Utilisation d'Enums (EmailName) : Sécurisation des vérifications de règles métier spécifiques (copies cachées BCC) sans s'appuyer sur des chaînes littérales disséminées.
- Lisibilité accrue des tests PHPUnit : Instanciation explicite des objets de contexte sans devoir assembler des tableaux de mocks imbriqués à la main.
- Gestion pragmatique des compromis : Conservation d'un objet de contexte mutable adapté au pipeline existant plutôt qu'une refonte théorique complexe.
1. Introduction : le problème des contrats implicites dans le code legacy
Dans des applications Symfony éprouvées et maintenues depuis plusieurs années, il est fréquent de voir des traitements métier clés s'appuyer sur la circulation de tableaux associatifs (array $data). Les fonctions reçoivent puis transmettent des structures comme $data['section'] ou $data['variables']['link'] d'un service à un autre.
Si ce choix initial offre de la flexibilité au démarrage d'un projet, il tend à rendre le contrat de données implicite. Sans consulter l'implémentation interne de chaque service traversé, il devient difficile de savoir quelles clés sont nécessaires, lesquelles sont facultatives et lesquelles sont enrichies en cours de traitement. De plus, les environnements de développement ne disposent d'aucune autocomplétion native sur ces structures, et une erreur de frappe telle que $email['seciton'] ne se révèle qu'au moment de l'exécution.
Ce retour d'expérience détaille la refactorisation du composant d'envoi d'emails transactionnels d'une application Symfony. L'objectif n'était pas d'appliquer une recette universelle "les objets sont toujours préférables aux tableaux", mais de rendre le contrat de données explicite en introduisant deux classes de contexte : EmailVo et EmailVariablesVo.
Note importante et confidentialité : Pour respecter mes obligations de confidentialité et préserver la propriété intellectuelle, l'ensemble des extraits et exemples de code présentés ici a été entièrement recréé et anonymisé. De ce fait, des imprécisions ou de légères erreurs peuvent exister dans les exemples : le but de cet article est d'illustrer la démarche d'architecture et la réflexion technique, et non de fournir du code prêt à l'emploi.
2. Le fonctionnement du système d'emails transactionnels
Dans cette application de réservation, l'envoi d'emails transactionnels accompagne l'ensemble du parcours utilisateur : confirmations de réservation, relances de paiement, notifications de messagerie ou demandes d'avis.
Pour acheminer un email, le composant exécute une séquence d'étapes ordonnées :
- Chargement des textes traduits (EmailTexts) selon la langue du destinataire (User).
- Remplacement dynamique des variables dans le sujet, le corps du message et le gabarit global (template).
- Génération des liens de redirection internes incluant leurs paramètres d'analyse et de suivi.
- Initialisation du journal d'envoi (EmailLogs) et injection du pixel de suivi.
- Transmission effective du message via le prestataire (service d'envoi ou API externe Mandrill).
3. Le code d'origine : un contrat de données dispersé
Dans la version initiale, la méthode newEmail du service EmailSender acceptait quatre arguments distincts, dont deux tableaux associatifs à la structure non formalisée :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Email;
use App\Domain\Entity\User;
use App\Domain\Manager\EmailSenderInterface;
class EmailSender implements EmailSenderInterface
{
/**
* @param ?string $env Inutilisé (laissé à null)
* @param User $user Destinataire de l'email
* @param array $email Tableau associatif : ["section", "name"]
* @param array $aVariable Tableau associatif : ["link", "subject", "body", "template"]
*/
public function newEmail($env, User $user, array $email, array $aVariable): ?array
{
if (null === ($rawEmail = $this->emailTexts->getThisEmailV1($user, $email))) {
return null;
}
$email = $this->emailContent->buildContent($rawEmail, $user, $aVariable);
$emailLogs = $email['emailLogs'];
// Envoi de l'email
$sendResult = $this->send($user, $email);
return $email;
}
}
Analyse des contraintes de l'ancienne implémentation :
- Absence de contrat formalisé dans les signatures : Les tableaux $email et $aVariable laissaient reposer la présence des clés (section, name, body) sur des docblocks ou des conventions non contrôlées par le langage.
- Mutation implicite de la structure : La méthode buildContent réattribuait la variable $email et lui ajoutait à la volée de nouvelles clés ($email['emailLogs']), modifiant la nature du tableau en cours de route.
- Présence d'arguments caducs : Le paramètre $env restait présent dans la signature par inertie, bien que devenu inutile pour le traitement.
- Difficulté d'analyse statique : Sans typage explicite des structures d'entrée et de sortie, les outils d'analyse de code comme PHPStan ne pouvaient pas vérifier la présence des données transmises.
4. Diagnostic et objectifs du refactoring
L'objectif principal du refactoring était de rendre le contrat de données explicite entre les différentes couches du système d'emailing, tout en maintenant la compatibilité avec la logique de compilation existante.
Plutôt que d'entreprendre une refonte globale de l'ensemble du moteur de rendu, nous avons ciblé quatre axes d'amélioration :
- Simplifier et clarifier les signatures : Remplacer l'accumulation de paramètres par la transmission d'un objet unique portant l'état du message.
- Rendre la structure navigable : Remplacer l'accès par clés sous forme de chaînes de caractères par des méthodes d'accès getters et setters.
- Sécuriser les règles de routage métier : Identifier les comportements d'envoi spécifiques via un Enum dédié (EmailName) plutôt que par des comparaisons de sous-chaînes.
- Faciliter la rédaction des tests : Permettre aux tests unitaires d'instancier directement des objets de contexte sans devoir simuler la structure exacte de tableaux associatifs complexes.
5. La nouvelle approche : expliciter le contrat via des objets de contexte
Pour répondre à ces objectifs, deux classes ont été introduites dans le namespace de l'application : EmailVariablesVo et EmailVo.
Précision terminologique : Bien qu'identifiées par le suffixe historique Vo au sein de cette base de code, ces classes doivent être comprises comme des objets de contexte et d'accumulation d'état pour le pipeline d'envoi, plutôt que comme des Value Objects immuables au sens strict du DDD. L'utilisation de setters permet d'enrichir progressivement le contexte au fil des étapes de compilation (génération des liens, injection des logs, compilation des sujets et corps).
La classe EmailVariablesVo regroupe les tableaux de variables nécessaires au remplacement dans les différentes parties du message :
<?php
declare(strict_types=1);
namespace App\Application\Vo\Email;
final class EmailVariablesVo
{
private array $link = [];
private array $subject = [];
private array $body = [];
private array $template = [];
public function getLink(): array
{
return $this->link;
}
...
public function setTemplate(array $template): self
{
$this->template = $template;
return $this;
}
}
L'utilisation de tableaux bruts (array) pour stocker ces variables relève d'un compromis pragmatique afin d'éviter d'ajouter une complexité excessive lors de la refactorisation initiale. Toutefois, cette partie mériterait ultérieurement une refactorisation et une sécurisation accrue (par exemple via des DTOs typés) pour renforcer le typage et la fiabilité des données manipulées.
La classe EmailVo centralise quant à elle l'ensemble des informations de l'email, de ses entités associées (User, EmailTexts, EmailLogs) et de son état compilé :
<?php
declare(strict_types=1);
namespace App\Application\Vo\Email;
use App\Domain\Entity\EmailLogs;
use App\Domain\Entity\EmailTexts;
use App\Domain\Entity\User;
final class EmailVo
{
private User $recipientUser;
private string $enumName;
private string $name;
private string $section;
private ?EmailVariablesVo $variables = null;
private ?EmailTexts $template = null;
private ?string $templateString = null;
private ?EmailTexts $emailText = null;
private ?EmailLogs $emailLogs = null;
private ?array $linksTemplate = [];
private ?array $links = [];
private ?string $subject = null;
private ?string $body = null;
public function getRecipientUser(): User
{
return $this->recipientUser;
}
...
public function setEmailLogs(EmailLogs $emailLogs): self
{
$this->emailLogs = $emailLogs;
return $this;
}
}
6. Tableau comparatif Avant / Après
| Axe d'analyse | Avant (Tableaux associatifs legacy) | Après (Objets de contexte EmailVo & EmailVariablesVo) |
|---|---|---|
| Définition du contrat | Clés sous forme de chaînes de caractères dispersées sans validation par le langage. | Contrat formalisé par une classe et son API de méthodes (getRecipientUser(), setBody()). |
| Circulation des données | Réécriture directe dans la variable de tableau au fil de l'exécution des méthodes. | Accès et modification via des méthodes d'accès explicitement identifiables. |
| Préparation des tests | Obligation d'assembler manuellement des structures de tableaux à clés multiples. | Instanciation directe de l'objet et appel ciblé des setters nécessaires au test. |
7. Architecture et déroulement du traitement refactorisé
L'organisation globale du composant s'appuie désormais sur une séparation claire des rôles : la préparation du contexte, sa compilation textuelle, puis son acheminement.
8. Choix d'implémentation et intégration dans les services
A. Compilation des contenus dans EmailContent
Le service EmailContent s'appuie désormais sur les méthodes de l'instance EmailVo pour lire la configuration du message et enregistrer les contenus générés :
<?php
namespace App\Infrastructure\Email;
use App\Application\Vo\Email\EmailVariablesVo;
use App\Application\Vo\Email\EmailVo;
use App\Domain\Entity\EmailLogs;
use App\Domain\Entity\User;
class EmailContent
{
public function buildContent(EmailVo $email): void
{
$user = $email->getRecipientUser();
$aVariable = $email->getVariables();
$emailTemplate = $email->getTemplate();
$emailTexts = $email->getEmailText();
$templateSectionName = 'various';
$templateName = 'template';
$email->setLinksTemplate($this->buildLink($user, $emailTemplate, $aVariable, $templateSectionName, $templateName));
$email->setTemplateString($this->buildBody($emailTemplate, $aVariable, $email->getLinksTemplate()));
$email->setLinks($this->buildLink($user, $emailTexts, $aVariable, $email->getSection(), $email->getName()));
$email->setSubject($this->buildSubject($emailTexts, $aVariable));
$email->setBody($this->buildBody($emailTexts, $aVariable, $email->getLinks()));
$aVariableTemplate = $aVariable->getTemplate();
$aVariableTitle = ['@TITLE@' => $email->getSubject()];
$aVariable->setTemplate(array_merge($aVariableTemplate, $aVariableTitle));
$email->setVariables($aVariable);
$template = $this->fillTemplate(
$email->getVariables()->getTemplate(),
$email->getRecipientUser(),
$email->getSection(),
$email->getName(),
$email->getTemplateString(),
);
$email->setEmailText($this->emailTextsRepository->findOneByOldParams($user->getLanguageVersion(), $email->getSection(), $email->getName()));
$emailLogs = $this->processEmailLogs($email);
$email->setEmailLogs($emailLogs);
$email->setBody(str_replace('@CONTENT@', $email->getBody(), $template));
$email->setBody($this->replaceLinks($user, $email->getBody(), $email->getVariables()->getBody(), $email));
}
}
B. Envoi et routage sécurisé dans EmailSender
La méthode d'entrée du service EmailSender accepte désormais uniquement l'instance EmailVo. Pour les règles d'envoi conditionnelles (comme l'ajout de copies cachées pour les demandes d'avis), l'implémentation s'appuie sur l'Enum EmailName :
<?php
namespace App\Infrastructure\Email;
use App\Application\Vo\Email\EmailVo;
use App\Domain\Enum\EmailName;
use App\Domain\Manager\EmailSenderInterface;
use App\Domain\Utils\LogUtils;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
class EmailSender implements EmailSenderInterface
{
public function newEmail(EmailVo $email): void
{
if ($this->serverParams->isAppEnvTest()) {
return;
}
try {
$this->emailTexts->getThisEmailV1($email);
} catch (\Exception $e) {
LogUtils::exception2($e, 'getThisEmailV1 could not retrieve Texts for email :', ['email' => $email]);
return;
}
$this->emailContent->buildContent($email);
$emailLogs = $email->getEmailLogs();
// Remplacement de l'URL du logo par le pixel de suivi
$pixelUrl = $this->getPixelUrl($emailLogs);
$email->setBody(str_replace(['@LOGO_URL@'], $pixelUrl, $email->getBody()));
$sendResult = $this->send($email);
if ($sendResult) {
$emailLogs->setStatus($sendResult['status']);
$emailLogs->setApi($sendResult['api']);
$this->em->flush();
} else {
LogUtils::error(sprintf('Empty Email: [%s][%s]', $email->getSection(), $email->getName()));
}
}
public function sendViaMandrill(EmailVo $emailData): array
{
$emailFullName = $emailData->getSection() . '_' . $emailData->getName();
$userEmail = $emailData->getRecipientUser()->getUserEmailByEmailName($emailFullName);
$from = new Address('noreply@example.com', 'Example');
$replyTo = new Address('noreply@example.com', 'No Reply');
$email = (new Email())
->from($from)
->addReplyTo($replyTo)
->subject($emailData->getSubject())
->text(strip_tags($emailData->getBody()))
->html($emailData->getBody());
// Copie cachée pour les demandes d'avis identifiées par Enum
if (in_array($emailData->getEnumName(), [
EmailName::REVIEWS_TO_GUEST_FOR_TP,
EmailName::REVIEWS_TO_HOST_FOR_TP,
], true)) {
$email->addBcc('user@example.com');
}
// Envoi effectif...
return ['api' => 'mandrill', 'email' => $userEmail, 'user' => $emailData->getRecipientUser()];
}
}
C. Écriture des tests unitaires PHPUnit
L'utilisation d'objets de contexte simplifie la préparation des données dans les tests. Au lieu de composer un grand tableau associatif avec des risques d'omission de clés, la classe de test instancie directement EmailVo et configure les propriétés ciblées :
<?php
declare(strict_types=1);
namespace Tests\Infrastructure\Manager;
use App\Application\Vo\Email\EmailVariablesVo;
use App\Application\Vo\Email\EmailVo;
use App\AppKernelTest;
final class EmailManagerLinksTest extends AppKernelTest
{
public function testBuildContentGeneratesTrackingLinks(): void
{
$host = $this->createHostUser();
$emailText = $this->createEmailText();
$templateEmailText = $this->createTemplateEmailText();
$fakeEmailData = new EmailVo();
$fakeEmailData->setRecipientUser($host);
$fakeEmailData->setSection('fakeSection');
$fakeEmailData->setName('fakeEmailName');
$fakeEmailData->setEmailText($emailText);
$fakeEmailData->setTemplate($templateEmailText);
$bodyVar = $this->emailManager->getBodyVarEmails($host, null, null);
$linkVar = $this->emailManager->getLinkVarEmails($host, null, null);
$aVariable = new EmailVariablesVo();
$aVariable->setLink($linkVar);
$aVariable->setSubject($bodyVar);
$aVariable->setBody($bodyVar);
$aVariable->setTemplate([]);
$fakeEmailData->setVariables($aVariable);
$emailContent = self::getContainer()->get(EmailContent::class);
$emailContent->buildContent($fakeEmailData);
$this->assertNotNull($fakeEmailData);
$this->assertTrue(
str_contains($fakeEmailData->getBody(), 'https://fr-fr.example.test/email-redirect/'),
'Le corps du message doit contenir le lien de redirection de test.'
);
}
}
D. Extensibilité du conteneur de données
Lorsqu'une nouvelle fonctionnalité nécessite l'ajout d'informations complémentaires (comme la prise en charge de pièces jointes), il suffit d'enrichir la classe EmailVo avec la méthode adaptée :
final class EmailVo
{
/** @var array<int, AttachmentVo> */
private array $attachments = [];
public function addAttachment(AttachmentVo $attachment): self
{
$this->attachments[] = $attachment;
return $this;
}
}
9. Résultats observables
Le gain principal de ce refactoring est d'ordre architectural et qualitatif plutôt que mesuré par une métrique de performance brute ou de temps d'exécution.
Sur le périmètre du composant d'emailing, plusieurs améliorations directes sont observables dans la base de code :
- Simplification des signatures d'interfaces : La méthode newEmail(EmailVo $email) exprime clairement son intention en acceptant un objet de contexte unique au lieu de quatre arguments dont des tableaux associatifs et un paramètre mort.
- Rattachement des données à une API explicite : Les accès aux propriétés se font via des méthodes lisibles (getRecipientUser(), getSubject()), facilitant la navigation dans le code et la refactorisation automatisée via l'IDE.
- Sécurisation des vérifications métier par Enum : Les contrôles d'adresses en copie cachée dans sendViaMandrill utilisent désormais la constante typée EmailName::REVIEWS_TO_GUEST_FOR_TP au lieu de chaînes de caractères brutes écrites en dur.
- Lisibilité des scénarios de test : Les tests unitaires construisent leurs jeux de données en manipulant directement l'instance de EmailVo, ce qui rend l'intention des cas de test plus immédiatement compréhensible.
10. Limites et compromis de la solution
Il est essentiel de porter un regard objectif sur ce refactoring : la solution retenue constitue un compromis pragmatique adapté à une base de code existante, et non une architecture parfaite.
Trois limites principales restent observables dans l'implémentation actuelle :
1. Accumulation de responsabilités dans un objet mutable :
La classe EmailVo agit comme un conteneur d'état partagé qui traverse plusieurs services (EmailContent, EmailSender). Elle regroupe à la fois des entités de domaine (User, EmailTexts), des identifiants de templates, des tableaux de variables, des entités de traçabilité (EmailLogs) et le contenu HTML final. Bien que cette centralisation simplifie les signatures, elle crée un objet à fortes responsabilités manipulé en mutation tout au long du pipeline.
2. Typage partiel des tableaux internes de variables :
La classe EmailVariablesVo encapsule les accès aux variables, mais ses propriétés internes ($link, $subject, $body, $template) restent définies comme des tableaux PHP (array). Les clés et valeurs contenues à l'intérieur de ces tableaux ne sont donc pas typées individuellement par des objets dédiés.
3. Représentation mixte de la propriété Enum :
Dans la classe EmailVo, la propriété $enumName est déclarée sous forme de chaîne de caractères (private string $enumName) avec un getter retournant une string. Cela nécessite d'effectuer des comparaisons via in_array(..., true) dans EmailSender plutôt que d'exploiter un type Enum strict directement au niveau du champ de la classe.
11. Retour d'expérience
La refactorisation d'un composant critique en production implique de trouver le bon équilibre entre pureté architecturale et risques de régression.
Ce travail sur le module d'envoi d'emails met en avant plusieurs enseignements applicables à la gestion du legacy Symfony :
- Expliciter le contrat est une première étape essentielle : Avant même de chercher à reconcevoir complètement l'architecture d'un service, remplacer des tableaux anonymes par des classes typées permet d'éclairer le fonctionnement réel du système.
- Préférer un compromis utile à un blocage théorique : Introduire des objets de contexte mutables peut sembler imparfait sur le plan du DDD strict, mais cela permet d'assainir immédiatement les interfaces sans devoir réécrire l'intégralité du moteur de rendu.
12. Conclusion
Rendre explicite un contrat de données dans une application Symfony ne nécessite pas toujours une refonte spectaculaire. En remplaçant la circulation de tableaux associatifs implicites par des classes de contexte adaptées comme EmailVo et EmailVariablesVo, le composant d'emailing gagne en lisibilité et en maintenabilité.
L'essentiel réside dans la clarté des intentions : définir des structures nommées, documenter les choix d'implémentation et assumer les compromis techniques concédés pour maintenir le système en production.