Aller au contenu principal
Retour au blog

Symfony Mailer en 2026 : RemoteTemplateEmail et le transport Microsoft Graph

Flavien Métivier3 octobre 20268 min

Un email transactionnel bien fait ressemble de moins en moins à un fichier Twig compilé côté serveur. En 2026, les équipes marketing et support pilotent de plus en plus leurs templates directement chez le fournisseur — Brevo, Mailjet, SendGrid — pour itérer sur un wording ou un A/B test sans dépendre d'un déploiement applicatif. Le Mailer de Symfony a longtemps imposé l'inverse : le corps de l'email se construit dans l'application, avec Twig ou du HTML brut, puis part vers un transport qui ne fait que le relayer. Deux évolutions récentes du composant changent la donne. La nouvelle classe RemoteTemplateEmail (PR #64782) inverse ce postulat pour les cas où ça a du sens, et la correction du transport Microsoft Graph (PR #64605) élargit en parallèle les options de routage pour les organisations sous Microsoft 365. Ce sont deux chantiers distincts du Mailer, mais ils se combinent naturellement dans un cas très concret : un email marketing rendu par le CRM d'un côté, une notification interne routée via l'infrastructure M365 de l'entreprise de l'autre — le tout piloté depuis le même point d'entrée applicatif.

RemoteTemplateEmail : déplacer le rendu chez le fournisseur

Techniquement, RemoteTemplateEmail est une nouvelle classe du composant Mailer, sœur de TemplatedEmail mais avec un contrat inversé. Là où TemplatedEmail demande un template Twig local et laisse le Mailer le compiler avant l'envoi, RemoteTemplateEmail ne transporte qu'une référence de template — l'identifiant que le fournisseur connaît de son côté — accompagnée d'un tableau de variables. Aucun HTML ni sujet compilé localement ne transite dans le message : c'est le provider qui reçoit la requête d'API et qui assemble l'email au moment de l'envoi, avec sa propre logique de rendu, ses propres blocs conditionnels et souvent son propre système de prévisualisation pour les équipes non techniques.

<?php
declare(strict_types=1);

use Symfony\Component\Mailer\RemoteTemplateEmail;

$email = (new RemoteTemplateEmail())
    ->from('notifications@thebeardedbear.dev')
    ->to($user->getEmail())
    ->template('welcome-onboarding-v3', [
        'firstName' => $user->getFirstName(),
        'accountUrl' => $urlGenerator->generate(
            'app_account_dashboard',
            [],
            UrlGeneratorInterface::ABSOLUTE_URL
        ),
        'trialDaysLeft' => 14,
    ]);

$mailer->send($email);

La méthode template() attend deux arguments : la référence du template côté fournisseur — une chaîne opaque pour Symfony, propre à chaque provider — et un tableau associatif de variables transmis tel quel dans le payload d'API. Le garde-fou intéressant est ailleurs : un RemoteTemplateEmail refuse explicitement qu'on lui attache un corps HTML ou texte local en plus du template distant, une exception étant levée dès la construction du message plutôt qu'à l'envoi. Côté transport, seuls ceux qui implémentent la nouvelle interface RemoteTemplateTransportInterface acceptent ce type de message : router un RemoteTemplateEmail vers un transport SMTP classique ou vers un pont qui n'a pas encore été mis à jour lève une LogicException explicite, plutôt que d'échouer silencieusement ou d'ignorer le template. Avant cette abstraction, chaque pont gérait ces templates distants à sa façon, avec des en-têtes spécifiques — templateid/params côté Brevo, X-MJ-TemplateID côté Mailjet — ce qui rendait le code applicatif dépendant du fournisseur dès qu'on voulait ce comportement. RemoteTemplateEmail centralise l'interopérabilité au niveau du composant Mailer lui-même, indépendamment du pont utilisé en coulisses. Ce mouvement vers l'API du provider est cohérent avec un autre chantier déjà traité sur ce blog : les webhooks entrants avec le Symfony Webhook component, souvent utilisés en miroir pour recevoir les événements de livraison — bounce, ouverture, clic — que ces mêmes providers renvoient une fois le RemoteTemplateEmail effectivement envoyé.

Quand déléguer le rendu, quand le garder en interne

Ce choix d'architecture n'est pertinent que pour une catégorie précise d'emails. Sur les templates transactionnels pilotés par l'équipe marketing ou support — bienvenue, relance de panier, notification produit — laisser le provider gérer le rendu a un intérêt réel : l'équipe non technique itère sur le design et le texte sans dépendre d'un déploiement applicatif, et le fournisseur applique nativement ses propres tests A/B et son propre tracking d'ouverture. Sur des emails porteurs de logique métier fine ou de contenu sensible — récapitulatif de commande avec calculs de TVA, lien de réinitialisation de mot de passe, notification contenant des données personnelles — le rendu local avec TemplatedEmail reste préférable : le contenu ne transite jamais par une API tierce avant d'être formé, et la traçabilité du contenu réellement envoyé demeure entièrement dans l'application. Ce choix de laisser un tiers gérer un pan entier du cycle de vie applicatif rejoint d'ailleurs la logique du Symfony 8.1 qu'on décortiquait dans l'article sur la stack HTTP-less : un worker Messenger qui ne fait qu'appeler l'API d'un mailer distant n'a besoin ni de Twig ni du router, exactement le genre de processus léger que Symfony 8.1 cible en priorité.

  • Candidats naturels pour RemoteTemplateEmail — newsletters, onboarding marketing, relances comportementales pilotées par le CRM
  • À garder en TemplatedEmail local — emails réglementaires, données personnelles sensibles, contenu calculé côté métier (facturation, TVA, montants)
  • Point de vigilance RGPD — vérifier le sous-traitant (DPA) du fournisseur avant de lui transmettre des variables contenant des données personnelles, même via une simple référence de template

Le transport Microsoft Graph : DSN et prérequis Azure AD

Le deuxième chantier mailer de cette période concerne le pont Microsoft Graph, déjà présent dans l'écosystème Symfony depuis la 7.4 mais victime d'une incohérence agaçante. La classe MicrosoftGraphTransportFactory existait, mais une omission dans Transport::FACTORY_CLASSES faisait que Transport::fromDsn() levait une UnsupportedSchemeException sur un DSN microsoftgraph+api://, alors que ce même DSN fonctionnait correctement via l'intégration FrameworkBundle. La PR #64605 corrige cette incohérence : le transport est désormais reconnu de façon identique, qu'on construise le Mailer via le framework ou qu'on instancie Transport à la main — un détail qui compte dès qu'on écrit des tests unitaires sur la configuration mailer sans booter le kernel complet.

Besoin d'un expert Symfony ?

Réserver un appel →
# .env — organisation sous Microsoft 365
MAILER_DSN=microsoftgraph+api://CLIENT_APP_ID:CLIENT_APP_SECRET@default?tenantId=TENANT_ID

# Variante datacenter Chine
# MAILER_DSN=microsoftgraph+api://CLIENT_APP_ID:CLIENT_APP_SECRET@microsoftgraph.chinacloudapi.cn?tenantId=TENANT_ID&authEndpoint=login.partner.microsoftonline.cn

# Option : ne pas archiver les envois dans les Éléments envoyés du compte de service
MAILER_DSN="${MAILER_DSN}&noSave=true"

Ce transport suppose un enregistrement d'application côté Azure AD, avec la permission d'application Microsoft Graph Mail.Send accordée par un administrateur du tenant, et un secret client généré pour l'authentification OAuth2 en client credentials. L'adresse d'expédition doit correspondre à un compte réellement présent dans l'annuaire du tenant — Microsoft Graph refuse d'usurper une adresse externe, contrairement à ce qu'un relais SMTP mal configuré tolère parfois. Le paramètre noSave=true évite d'archiver systématiquement chaque message envoyé dans le dossier Éléments envoyés du compte expéditeur, ce qui a du sens pour un compte de service qui n'envoie que des notifications automatisées et qu'on ne veut pas voir grossir indéfiniment. Pour une équipe déjà sous M365, ce transport évite de maintenir un relais SMTP tiers uniquement pour router les emails internes — les mêmes credentials applicatifs Azure AD servent à l'authentification Graph API utilisée par ailleurs pour Teams ou SharePoint.

Router les deux transports depuis Messenger

Dans une application qui envoie à la fois des notifications internes via M365 et des emails marketing rendus par le provider CRM, la bonne pratique reste de déclarer les deux transports nommés côté configuration et de choisir celui qui convient au niveau du message, pas dans le code métier qui le construit. Cela garde un point d'entrée unique — $mailer->send() — pour tous les types d'email, quel que soit le transport visé.

# config/packages/mailer.yaml
framework:
    mailer:
        transports:
            main: '%env(MAILER_DSN)%'
            graph: '%env(MAILER_GRAPH_DSN)%'

Pour cibler explicitement un transport nommé sur un message donné, le Mailer expose l'en-tête interne X-Transport : on l'ajoute sur l'email avant l'envoi, et le routage se fait sans toucher au reste du code appelant. C'est particulièrement utile dans un handler Messenger générique qui traite plusieurs types de notifications et doit dispatcher chacune vers le bon canal.

<?php
// src/MessageHandler/SendOnboardingEmailHandler.php
declare(strict_types=1);

namespace App\MessageHandler;

use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mailer\RemoteTemplateEmail;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class SendOnboardingEmailHandler
{
    public function __construct(
        private readonly MailerInterface $mailer,
    ) {
    }

    public function __invoke(SendOnboardingEmail $message): void
    {
        $email = (new RemoteTemplateEmail())
            ->from('notifications@thebeardedbear.dev')
            ->to($message->recipient)
            ->template('welcome-onboarding-v3', $message->variables);

        // Route explicitement vers le transport marketing (provider CRM)
        $email->getHeaders()->addTextHeader('X-Transport', 'main');

        $this->mailer->send($email);
    }
}

Côté Messenger, ce handler bénéficie de la même politique de retry que n'importe quel autre message asynchrone — un sujet détaillé en profondeur dans l'article sur les workers Messenger, leur supervision et leurs stratégies de retry en production. Un échec transitoire de l'API du provider CRM ou de Microsoft Graph — quota atteint, timeout réseau, jeton expiré — ne doit jamais bloquer la requête HTTP qui a déclenché l'envoi ; c'est tout l'intérêt de dispatcher via Messenger plutôt que d'appeler $mailer->send() en synchrone dans un contrôleur. Sur le transport Graph en particulier, prévoir un retry avec backoff exponentiel absorbe naturellement les erreurs 429 renvoyées par l'API Microsoft Graph lors des pics d'envoi, sans qu'un email de notification interne ne se perde silencieusement.

Ces deux évolutions du Mailer illustrent une tendance de fond : Symfony ne cherche plus à tout faire tourner dans le processus applicatif, mais à s'interfacer proprement avec l'écosystème externe — provider CRM, plateforme cloud, service d'identité d'entreprise. Faire les bons choix d'architecture sur ces intégrations — quel type d'email confier à un tiers, comment router les transports selon la sensibilité des données, comment superviser les échecs d'envoi en production — demande souvent un regard extérieur, surtout quand la stack mailer d'un projet s'est construite au fil de l'eau, transport après transport, sans jamais être reprise dans son ensemble. C'est exactement le type d'audit qu'un accompagnement Symfony sérieux permet de mener avant que ces choix ne deviennent difficiles à défaire.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.