Form-to-Email : une bibliothèque PHP 8.4 volontairement petite pour un traitement de formulaires et une livraison d’e-mails contrôlés

TL;DR Un simple formulaire de contact finit souvent par embarquer un framework, un fournisseur SaaS, ou un script maison fragile. Form-to-Email est une bibliothèque PHP 8.4+ uniquement backend qui traite la gestion des formulaires comme une chaîne de données stricte : assainir, valider, transformer, puis envoyer, et journaliser.

Tout est explicite : ordre des processeurs, remontée des erreurs, livraison des e-mails, et journalisation d’audit.

L’objectif n’est pas la flexibilité ni la popularité, mais le contrôle, la prévisibilité, et la maintenabilité à long terme.

Form-to-Email sur GitHub https://github.com/jturbide/form-to-email

Form-to-Email: a small PHP library for form processing and email delivery

Le problème, en code

La plupart des formulaires de contact PHP commencent petit et semblent raisonnables au premier coup d’œil.

if (!empty($_POST['email']) && filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
    mail('contact@example.com', 'New message', $_POST['message']);
}

Cela fonctionne pour une démo. Ça fonctionne même en production pendant un certain temps. Le problème n’est pas que c’est faux, mais que cela n’a pas de trajectoire de croissance claire.

Dès que les exigences changent, le code commence à se déformer. Vous ajoutez le trim, des vérifications de longueur, une protection anti-spam, de la journalisation, peut-être un second destinataire. La logique de validation fuit dans la logique de transport. La gestion des erreurs devient implicite. Les tests deviennent pénibles.

Une version un peu plus réaliste commence déjà à montrer le problème :

$errors = [];
$email = trim($_POST['email'] ?? '');
$message = trim($_POST['message'] ?? '');

if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors['email'][] = 'Invalid email';
}

if (strlen($message) < 10) {
    $errors['message'][] = 'Message too short';
}

if ($errors === []) {
    mail('contact@example.com', 'New message', $message);
}

À ce stade, plusieurs problèmes sont déjà visibles :

  • La normalisation des entrées, la validation, et les effets de bord sont mélangés
  • L’ordre d’exécution est implicite et facile à casser
  • Les erreurs sont des chaînes ad hoc sans structure
  • Il n’existe pas de modèle réutilisable pour un autre formulaire
  • Les tests nécessitent de simuler $_POST et l’état global

Aucun de ces problèmes n’est dramatique pris isolément. Ensemble, ils rendent la gestion des formulaires fragile et difficile à raisonner.

Form-to-Email part de l’observation que ce problème n’est pas l’envoi d’un e-mail. Il s’agit de définir une chaîne prévisible où chaque étape est explicite, ordonnée, et testable, et où la livraison d’e-mail n’est que la conséquence finale de données valides.


La chaîne, visuellement et concrètement

Form-to-Email traite la gestion des formulaires comme une chaîne de données stricte et linéaire. Rien n’est inféré et rien n’est réordonné. Chaque étape est déclarée au préalable et exécutée dans l’ordre que vous définissez.

À haut niveau, la chaîne ressemble à ceci :

Entrée brute (HTTP / JSON / CLI)
        ↓
Logger (debug, stdout)
        ↓
Filtres (assainir, normaliser)
        ↓
Règles (valider les invariants)
        ↓
Transformateurs (forme finale)
        ↓
Données structurées
        ↓
Adaptateur de mailer (effet de bord)
        ↓
Adaptateur de logger (données finales, journal d’audit)

Ce modèle est volontairement simple. Il reflète ce que la plupart des développeurs font déjà mentalement, mais force les étapes à être explicites et testables.

Un exemple minimal fonctionnel ressemble à ceci :

$form = new FormDefinition()
    ->add(new FieldDefinition('email', roles: [FieldRole::SenderEmail], processors: [
        new TrimFilter(),
        new SanitizeEmailFilter(),
        new EmailRule(),
    ]))
    ->add(new FieldDefinition('message', roles: [FieldRole::Body], processors: [
        new TrimFilter(),
        new RequiredRule(),
    ]));

$controller = new FormToEmailController(
    form: $form,
    mailer: $mailer,
    recipients: ['contact@example.com'],
);

$controller->handle();

Ce qui compte ici n’est pas la syntaxe, mais la forme du code. Chaque champ déclare ses propres règles de traitement. Il n’y a pas de couche de validation globale et pas d’ordre d’exécution caché. Lire la définition vous dit exactement ce qui se passera à l’exécution.

Cette structure rend également les modes d’échec évidents. Si l’entrée est invalide, la chaîne s’arrête avant qu’un quelconque effet de bord ne se produise. Si le traitement réussit, la livraison de l’e-mail devient une conséquence directe de données validées, pas un endroit où s’accumule une logique supplémentaire.

En ancrant la conception dans une chaîne visible, la bibliothèque évite l’ambiguïté qui apparaît généralement lorsque la logique de gestion de formulaires commence à croître de manière organique.


Définir les champs : là où la plupart des bugs se produisent réellement

En pratique, la plupart des bugs liés aux formulaires ne viennent pas de la livraison d’e-mails. Ils viennent d’hypothèses au niveau des champs : à quel moment les valeurs sont nettoyées, à quel moment elles sont validées, et quelle version des données est réellement vérifiée.

Form-to-Email pousse toute cette logique dans FieldDefinition. Chaque champ possède son nom, son rôle sémantique, et sa chaîne de processeurs. Il n’y a pas d’état partagé et pas de magie inter-champs.

Une source fréquente de bugs subtils est l’ordre des processeurs. Considérez cette définition incorrecte :

new FieldDefinition('email', processors: [
    new EmailRule(),
    new SanitizeEmailFilter(),
]);

À première vue, cela semble raisonnable, mais la validation s’exécute sur l’entrée brute. Une adresse avec des espaces en tête ou des caractères Unicode mélangés peut échouer à la validation même si elle pourrait être normalisée en toute sécurité.

La version corrigée rend l’intention explicite :

new FieldDefinition('email', processors: [
    new TrimFilter(),
    new SanitizeEmailFilter(),
    new EmailRule(),
]);

Ici, la validation s’exécute sur la valeur normalisée. La différence n’est pas théorique ; elle affecte directement les faux négatifs et les erreurs visibles par l’utilisateur.

Les transformateurs suivent le même principe. Les opérations de formatage comme la mise en minuscules ou la canonicalisation devraient généralement se produire après la validation, afin que les règles opèrent sur une entrée significative plutôt que sur une représentation dérivée.

new FieldDefinition('email', processors: [
    new SanitizeEmailFilter(),
    new EmailRule(),
    new LowercaseTransformer(),
]);

En forçant l’ordre des processeurs à être explicite, la bibliothèque élimine toute une classe de comportements cachés. Lire la définition du champ suffit à comprendre exactement ce qui arrive aux données, dans quel ordre, et pourquoi.

Ce modèle centré sur les champs se met également à l’échelle proprement. Ajouter un nouveau champ n’affecte pas les existants, et réutiliser des chaînes de processeurs entre formulaires ne requiert ni héritage, ni traits, ni configuration globale. Chaque champ reste une petite chaîne autonome.


Rôles et mappage d’e-mail : supprimer le couplage caché

Une source fréquente de fragilité dans les gestionnaires de formulaires est le couplage implicite entre les noms de champs et la logique de composition d’e-mail. Le backend suppose souvent qu’une clé d’entrée spécifique correspond à un attribut d’e-mail spécifique, et cette hypothèse se propage silencieusement dans la base de code.

Un exemple typique ressemble à ceci :

$mail->setReplyTo($data['email']);
$mail->Subject = $data['subject'] ?? 'Contact form';
$mail->Body = $data['message'];

Cela fonctionne, mais le couplage est invisible. Renommer un champ ou réutiliser la même logique de formulaire dans un contexte différent nécessite de toucher à la fois au code de validation et au code de composition de l’e-mail. Avec le temps, ces hypothèses s’accumulent.

Form-to-Email casse ce couplage en introduisant des rôles de champ explicites. Un rôle décrit ce qu’un champ représente, pas comment il est nommé. Les rôles n’affectent ni la validation ni le traitement ; ils sont uniquement utilisés lors du mappage des données validées vers une charge utile d’e-mail.

new FieldDefinition('email', roles: [FieldRole::SenderEmail]);
new FieldDefinition('name', roles: [FieldRole::SenderName]);
new FieldDefinition('message', roles: [FieldRole::Body]);

Avec les rôles en place, la composition de l’e-mail ne dépend plus de noms de champs codés en dur. Le contrôleur construit la charge utile de l’e-mail par intention sémantique : adresse de l’expéditeur, nom de l’expéditeur, sujet, corps. Les noms de champs restent locaux à la définition du formulaire.

Cette approche a deux conséquences pratiques. Premièrement, les formulaires deviennent plus faciles à refactorer. Changer un nom d’entrée ne se répercute pas dans la logique du mailer. Deuxièmement, les définitions de formulaire deviennent plus réutilisables. La même chaîne peut être appliquée à différents frontends ou formats de requête sans réécrire le code d’e-mail.

Les rôles sont volontairement limités en portée. Ce ne sont pas des règles de validation, et ils n’influencent pas l’exécution des processeurs. Leur seul but est de rendre l’intention explicite à la frontière entre les données validées et la livraison d’e-mail, où les hypothèses cachées sont les plus coûteuses.


Erreurs structurées et intégration frontend

Une fois que la validation devient explicite, la gestion des erreurs doit l’être également. Des chaînes ad hoc ou des exit() précoces rendent difficile la construction de frontends fiables et rendent presque impossible de tester le comportement de manière cohérente.

Form-to-Email renvoie toujours un résultat structuré. Le succès et l’échec s’excluent mutuellement, et les échecs ne déclenchent jamais d’effets de bord partiels comme la livraison d’e-mails.

Une réponse typique d’erreur de validation ressemble à ceci :

{
  "code": "validation_error",
  "errors": {
    "name": [
      {
        "code": "too_short",
        "message": "The field "{field}" must be at least {min} characters long.",
        "context": {
          "field": "name",
          "min": 4,
          "length": 1
        },
        "field": "name"
      }
    ],
    "email": [
      {
        "code": "invalid_email",
        "message": "Invalid email format.",
        "context": {
          "value": "z",
          "normalized": null
        },
        "field": "email"
      },
      {
        "code": "too_short",
        "message": "The field "{field}" must be at least {min} characters long.",
        "context": {
          "field": "email",
          "min": 5,
          "length": 1
        },
        "field": "email"
      }
    ],
    "message": [
      {
        "code": "too_short",
        "message": "The field "{field}" must be at least {min} characters long.",
        "context": {
          "field": "message",
          "min": 10,
          "length": 1
        },
        "field": "message"
      }
    ]
  },
  "messages": {
    "name": [
      "The field "name" must be at least 4 characters long."
    ],
    "email": [
      "Invalid email format.",
      "The field "email" must be at least 5 characters long."
    ],
    "message": [
      "The field "message" must be at least 10 characters long."
    ]
  }
}

Chaque erreur est liée à un champ et exprimée avec un code d’erreur stable et un message lisible par un humain. Les frontends peuvent afficher ces erreurs directement, les mapper vers des chaînes localisées, ou appliquer un comportement UI personnalisé sans analyser du texte libre.

Le format de réponse est intentionnellement agnostique vis-à-vis du transport. Bien que JSON soit la sortie la plus courante, le contrôleur n’assume pas une technologie frontend spécifique. Une page HTML statique, une application JavaScript, ou un client mobile peuvent tous consommer la même structure.

Ce modèle d’erreurs explicite améliore également les tests. Des assertions peuvent être faites sur les codes d’erreur et la structure plutôt que sur des comparaisons de chaînes fragiles. À mesure que les règles de validation évoluent, le contrat avec le frontend reste stable.

En traitant la forme des erreurs comme une partie de l’API, la bibliothèque évite le piège courant où la logique de validation backend et la présentation frontend deviennent étroitement couplées via des conventions non documentées.


Des données validées à la livraison d’e-mails

Une fois que la chaîne a produit des données validées et normalisées, la responsabilité restante est simple : transformer ces données en un e-mail et l’envoyer. À ce stade, aucune logique de validation ne devrait subsister et aucune hypothèse sur l’entrée brute ne devrait exister.

Form-to-Email garde cette frontière explicite via un petit contrôleur dont le rôle est l’orchestration, pas la prise de décision. Le contrôleur exécute la chaîne du formulaire, inspecte le résultat, et soit renvoie des erreurs structurées soit procède à la livraison d’e-mail. Il ne réinterprète pas les champs, ne réordonne pas les processeurs, et n’applique pas de règles métier supplémentaires.

Lorsque la validation réussit, le contrôleur construit une charge utile de mail immuable à partir des valeurs traitées. Les rôles de champ guident cette étape. Un champ étiqueté comme e-mail de l’expéditeur devient l’adresse de reply-to. Un champ étiqueté comme corps devient le contenu du message. Ce mappage est sémantique plutôt que positionnel et ne dépend pas des noms de champs.

$controller = new FormToEmailController(
    form: $form,
    mailer: $mailer,
    recipients: ['contact@example.com'],
    defaultSubject: 'New contact request',
);

$controller->handle();

La charge utile du mail elle-même est un simple objet valeur. Elle contient les destinataires, le sujet, les corps en texte brut et HTML, et des métadonnées optionnelles telles que les informations de reply-to. Une fois construite, elle n’est plus mutée. Cela évite les surprises de dernière étape et rend le comportement de livraison plus facile à raisonner.

La livraison d’e-mail est traitée comme un effet de bord terminal. Si l’envoi échoue, l’échec est signalé explicitement. Si la validation échoue, la livraison n’est jamais tentée. Il n’y a pas d’état intermédiaire où des données partiellement valides aboutissent à l’envoi d’un message.

En isolant la livraison d’e-mail à la fin de la chaîne, la bibliothèque empêche un mode d’échec courant où les préoccupations de transport absorbent lentement la logique de validation et de formatage. La gestion des formulaires reste centrée sur la correction des données ; la livraison d’e-mail reste centrée sur l’infrastructure.


Adaptateurs de mailer et frontières d’infrastructure

La livraison d’e-mail est une préoccupation d’infrastructure. La traiter comme telle évite de laisser des détails de transport fuiter dans la validation, le traitement, ou les définitions de formulaire.

Form-to-Email impose cette séparation via une petite interface d’adaptateur de mailer. L’adaptateur reçoit une charge utile de mail entièrement construite et est responsable uniquement de la livrer. Il ne sait pas comment les données ont été validées, et il ne participe pas au traitement du formulaire.

L’adaptateur par défaut est basé sur PHPMailer, choisi pour sa maturité, son adoption large, et son comportement prévisible dans des environnements auto-hébergés. Ce choix est pragmatique, pas architectural. PHPMailer est un détail d’implémentation, pas une dépendance de conception.

$mailer = new PHPMailerAdapter(
    useSmtp: true,
    host: 'mail.example.com',
    username: 'no-reply@example.com',
    password: 'secret',
    fromEmail: 'no-reply@example.com',
    fromName: 'Website Contact Form',
);

Parce que la frontière de l’adaptateur est étroite, remplacer le mailer n’affecte pas le reste du système. Les définitions de formulaire, les chaînes de processeurs, la gestion des erreurs, et la journalisation restent inchangées. Seul le mécanisme de livraison varie.

Cela rend les décisions d’infrastructure explicites et réversibles. Passer de SMTP local à un autre transport, ou intégrer un fournisseur externe plus tard, ne nécessite pas de réécrire la logique de formulaire ni les règles de validation.

En contraignant la livraison d’e-mail derrière une interface unique, la bibliothèque maintient la gestion des formulaires focalisée sur la correction des données et l’intention, tout en permettant à l’infrastructure d’évoluer indépendamment.


Journalisation et observabilité dans des déploiements réels

Une fois qu’un gestionnaire de formulaires est déployé, les échecs se produisent rarement là où le code est évident. Ils apparaissent sous forme d’e-mails manquants, de soumissions partielles, ou de signalements utilisateurs impossibles à reproduire facilement. Sans observabilité, même des problèmes simples deviennent coûteux à diagnostiquer.

Form-to-Email traite la journalisation comme une préoccupation opérationnelle plutôt que comme une exigence centrale. La journalisation est optionnelle, explicite, et isolée du flux de contrôle. Si aucun logger n’est configuré, la bibliothèque reste silencieuse. Lorsqu’un logger est fourni, il reçoit des événements structurés décrivant ce qui s’est passé lors d’une soumission.

$logger = new Logger(
    file: __DIR__ . '/../logs/contact.log',
);

new FormToEmailController(
    form: $form,
    mailer: $mailer,
    recipients: ['contact@example.com'],
    logger: $logger,
)->handle();

Les événements journalisés peuvent inclure des échecs de validation, des soumissions réussies, et des résultats d’exécution de haut niveau. L’intention est de ne pas capturer chaque valeur intermédiaire, mais de fournir suffisamment de contexte pour comprendre le comportement en production sans exposer inutilement des données sensibles.

Point crucial, la journalisation n’influence pas l’exécution. Un échec de journalisation ne peut pas empêcher une soumission valide d’être traitée, et un échec de validation ne déclenche pas automatiquement une sortie verbeuse sauf configuration explicite. Cette séparation évite une classe fréquente de bugs où les préoccupations d’observabilité fuient dans la logique métier.

En pratique, cela facilite la corrélation entre les signalements frontend et le comportement backend, la détection de schémas d’échec répétés, et l’audit de l’activité des formulaires dans le temps. Le système reste silencieux par défaut, mais observable en cas de besoin.

En gardant la journalisation structurée, optionnelle, et non intrusive, la bibliothèque soutient les opérations du monde réel sans imposer une stratégie de monitoring ni forcer un choix d’infrastructure particulier.


Considérations de sécurité

La gestion des formulaires se situe directement sur une frontière de confiance. L’entrée utilisateur brute passe d’un environnement non fiable à une logique backend qui peut déclencher des effets de bord comme la livraison d’e-mails, la journalisation, ou la persistance. La bibliothèque est conçue pour rendre cette frontière explicite et difficile à contourner accidentellement.

Modèle de confiance des entrées

Form-to-Email part du principe que toutes les données entrantes ne sont pas fiables. Aucun champ n’est considéré comme sûr par défaut, et aucun assainissement implicite n’a lieu. Chaque transformation appliquée aux données d’entrée doit être déclarée explicitement dans la chaîne du champ.

Cette conception évite un mode d’échec courant où les développeurs supposent que la validation assainit implicitement les données, ou que les consommateurs en aval géreront correctement les valeurs dangereuses.

Assainissement avant validation

Un principe de sécurité central imposé par la bibliothèque est que la normalisation et l’assainissement doivent se produire avant la validation chaque fois que possible. Les validateurs opèrent sur des données normalisées, pas sur l’entrée brute. Cela réduit les faux négatifs et garantit que les règles de validation reflètent la forme réelle des données utilisée ensuite.

Par exemple, supprimer les espaces en tête/fin ou normaliser l’Unicode avant de valider une adresse e-mail évite des cas limites où une entrée valide est rejetée ou gérée de manière incohérente.

XSS et injection de contenu

Form-to-Email ne rend pas de HTML et ne génère pas de sortie frontend. Cela élimine une classe entière de risques de cross-site scripting dans la bibliothèque elle-même.

Cependant, l’entrée utilisateur peut tout de même se retrouver dans les corps d’e-mails, les logs, ou des systèmes en aval. Pour cette raison, la bibliothèque fournit des filtres explicites comme l’échappement HTML et la suppression de balises. Ces filtres sont optionnels et doivent être appliqués intentionnellement au niveau du champ.

L’absence d’échappement automatique est délibérée. Elle évite les bugs de double échappement et rend le modèle de sécurité visible dans le code plutôt qu’implicite.

Effets de bord uniquement sur des données valides

La livraison d’e-mail est traitée comme un effet de bord terminal. Si la validation échoue, aucun e-mail n’est envoyé et aucune action partielle n’a lieu. Cela empêche des scénarios d’abus où une entrée malformée ou partiellement validée pourrait tout de même déclencher des effets externes.

Limitation de débit et atténuation des abus

La bibliothèque n’implémente ni limitation de débit, ni CAPTCHA, ni throttling par IP. Ces préoccupations sont considérées comme des responsabilités au niveau de l’infrastructure. Lorsqu’elles sont nécessaires, elles doivent être gérées par un reverse proxy, un serveur web, un pare-feu, ou un middleware dédié.

Cela permet à la bibliothèque de rester focalisée sur la correction et évite d’embarquer des décisions de politique qui varient largement selon les déploiements.

Références

Les choix de conception liés à la sécurité dans cette bibliothèque s’alignent sur les recommandations suivantes :


Processeurs intégrés et extensibilité

Form-to-Email garantit la sécurité et la justesse grâce à de petits processeurs composables. Chaque processeur est attaché à un champ et exécuté dans un ordre strict et explicite. Il n’y a pas de pipeline global et aucun comportement implicite.

Les processeurs sont intentionnellement répartis en trois catégories. La distinction est sémantique : elle définit l’intention, pas la mécanique.

Type de processeurResponsabilitéModifie la valeurPeut échouer la validation
FiltreAssainir ou normaliser une entrée non fiableOuiNon
RègleFaire respecter des invariantsNonOui
TransformateurProduire une représentation canoniqueOuiNon

Tous les processeurs partagent le même contrat d’exécution et sont combinés directement sur FieldDefinition.

Filtres intégrés (assainissement et normalisation)

Les filtres s’exécutent tôt dans le pipeline. Leur rôle est de rendre l’entrée brute et non fiable prévisible afin que la validation opère sur des données normalisées.

Si un champ n’a aucun filtre, sa valeur brute est conservée délibérément.

FiltreObjectif
TrimFilterSupprimer les espaces en début et fin
StripTagsFilterSupprimer les balises HTML
HtmlEscapeFilterÉchapper les entités HTML pour un rendu sûr
SanitizeEmailFilterNormaliser les adresses email (compatible RFC, sûr pour IDN)
SanitizePhoneFilterSupprimer les caractères non numériques des numéros de téléphone
SanitizeTextFilterNormaliser les entrées de texte libre
RemoveUrlFilterSupprimer agressivement les URL intégrées
RemoveEmojiFilterSupprimer les émojis et symboles pictographiques
NormalizeNewlinesFilterNormaliser les retours à la ligne mixtes en \\n
CallbackFilterAppliquer un callable personnalisé

Les filtres ne signalent jamais d’erreurs de validation. Si le filtrage produit une valeur invalide, des règles de validation la détecteront plus tard.

Exemple :

$field = (new FieldDefinition('message'))
    ->addFilter(new TrimFilter())
    ->addFilter(new StripTagsFilter());

Règles intégrées (validation)

Les règles font respecter des invariants sur des données déjà normalisées. Elles inspectent les valeurs et renvoient des erreurs structurées sans modifier l’entrée ni lever d’exceptions.

RègleObjectif
RequiredRuleLa valeur doit être présente et non vide
EmailRuleValider la syntaxe d’un email
RegexRuleValider via une expression régulière
LengthRuleFaire respecter une longueur min/max
MinLengthRuleFaire respecter une longueur minimale
MaxLengthRuleFaire respecter une longueur maximale
CallbackRuleLogique de validation personnalisée

Les règles peuvent renvoyer zéro, une ou plusieurs erreurs. Un échec de validation stoppe les effets de bord tels que l’envoi d’email, mais ne met pas fin implicitement à l’exécution.

Exemple :

$field = (new FieldDefinition('message'))
    ->addRule(new RequiredRule())
    ->addRule(new MaxLengthRule(500));

Transformateurs intégrés (forme finale)

Les transformateurs s’exécutent après la validation et produisent une représentation canonique des données acceptées. Ils doivent être purs, déterministes et dépourvus de logique de validation.

TransformateurObjectif
LowercaseTransformerConvertir les chaînes en minuscules (compatible Unicode)
UcFirstTransformerMettre en majuscule le premier caractère
HtmlEntitiesTransformerConvertir les caractères en entités HTML
CallbackTransformerAppliquer une transformation callable personnalisée

Les transformateurs doivent être utilisés pour le formatage et la normalisation, pas pour faire respecter des contraintes.

Exemple :

$field = (new FieldDefinition('email'))
    ->addTransformer(new LowercaseTransformer());

Assembler le tout

Un pipeline de champ typique et bien structuré ressemble à ceci :

new FieldDefinition('email', processors: [
    new TrimFilter(),
    new SanitizeEmailFilter(),
    new EmailRule(),
    new LowercaseTransformer(),
]);

L’ordre est intentionnel :

  1. Les filtres nettoient les entrées non fiables
  2. Les règles valident les invariants
  3. Les transformateurs finalisent la représentation

Si une logique modifie les données et peut échouer la validation, elle doit être scindée en deux processeurs. Cette séparation maintient des pipelines prévisibles et faciles à raisonner.

Structure interne (référence)

src/
├── Filter/
├── Rule/
├── Transformer/
├── Core/

Cette organisation reflète le modèle d’exécution et maintient des points d’extension étroits, explicites et auditables.


Écrire vos propres processeurs (personnalisés, filtres, règles, transformateurs)

L’un des objectifs de conception fondamentaux de Form-to-Email est que le comportement personnalisé soit aussi facile à écrire que le comportement intégré. Il n’y a pas de système de plugins, pas d’étape d’enregistrement et pas de cycle de vie caché. Si vous pouvez écrire une petite classe prévisible, vous pouvez étendre le pipeline.

Tous les processeurs partagent le même modèle d’exécution et sont attachés directement à une FieldDefinition. La différence entre un filtre, une règle et un transformateur est sémantique, pas structurelle.

Types de processeurs en un coup d’œil

Le tableau ci-dessous résume les trois types de processeurs utilisés dans Form-to-Email, leur implémentation et quand ils doivent être utilisés.

Type de processeurObjectifClasse de base / interfaceQuand l’utiliser
FiltreAssainir ou normaliser une entrée brute non fiableAbstractFilterTôt dans le pipeline, avant toute validation, pour nettoyer ou normaliser les données entrantes
RègleValider des invariants et rapporter des erreurs structuréesAbstractRuleAprès normalisation, pour faire respecter des contraintes sans modifier les valeurs
TransformateurProduire une représentation canonique ou formatéeAbstractTransformerAprès validation, pour normaliser des valeurs en vue du stockage, de la comparaison ou de la livraison

Tous les processeurs partagent le même modèle d’exécution et sont attachés directement à une FieldDefinition. La distinction entre les types est sémantique : ce qui compte, c’est l’intention, pas la mécanique.

Écrire un processeur personnalisé

Le comportement personnalisé s’ajoute en implémentant l’interface de processeur partagée. Il n’y a pas de système de plugins séparé et pas d’enregistrement global.

final class SlugifyTransformer implements FieldProcessor
{
    public function process(
        mixed $value,
        FieldDefinition $field,
        FormContext $context
    ): mixed {
        $value = strtolower(trim((string) $value));
        $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
        return trim($value, '-');
    }
}

Le processeur peut ensuite être attaché comme n’importe quel composant intégré :

$field = (new FieldDefinition('title'))
    ->addTransformer(new SlugifyTransformer());

Parce que les processeurs sont locaux aux champs et exécutés dans l’ordre, la logique personnalisée reste prévisible et facile à auditer. Il n’y a pas de hooks cachés et aucun effet de bord global.

L’extensibilité s’arrête aux frontières des processeurs et des adaptateurs. Si une fonctionnalité ne peut pas être exprimée proprement à ces niveaux, elle appartient probablement en dehors de la bibliothèque.

Écrire un transformateur

Les transformateurs sont responsables de produire une représentation canonique d’une valeur. Ils ne doivent pas effectuer de validation et ne doivent pas rejeter des données. Leur rôle est de normaliser ou de formater une entrée déjà validée.

Un exemple concret est la mise en minuscules d’une saisie utilisateur, comme des adresses email ou des noms d’utilisateur.

<?php

declare(strict_types=1);

namespace FormToEmailTransformer;

use FormToEmailCoreFieldDefinition;
use FormToEmailCoreFormContext;

/**
 * Converts string values to lowercase.
 *
 * Features:
 * - Unicode-aware by default (uses mb_strtolower)
 * - Option to disable Unicode mode for legacy or performance
 * - Skips non-string values
 */
final class LowercaseTransformer extends AbstractTransformer
{
    public function __construct(
        private readonly bool $unicodeAware = true
    ) {
    }

    #[Override]
    public function apply(mixed $value, FieldDefinition $field, FormContext $context): mixed
    {
        if (!is_string($value)) {
            return $value;
        }

        return $this->unicodeAware
            ? mb_strtolower($value, 'UTF-8')
            : strtolower($value);
    }
}

Caractéristiques clés d’un transformateur :

  • Il est pur : la même entrée produit toujours la même sortie
  • Il ne lève jamais d’erreurs de validation
  • Il est sûr de ne s’exécuter qu’après assainissement et validation

Utilisation :

$field = (new FieldDefinition('email'))
    ->addTransformer(new LowercaseTransformer());

Écrire une règle de validation

Les règles font respecter des invariants. Elles inspectent une valeur et l’acceptent ou renvoient une ou plusieurs erreurs structurées. Les règles * *ne modifient jamais les valeurs**.

Un exemple typique est l’application d’une contrainte de longueur maximale.

<?php

declare(strict_types=1);

namespace FormToEmailRule;

use FormToEmailCoreFieldDefinition;

/**
 * Rule: MaxLengthRule
 *
 * Ensures that a string does not exceed a given number of characters.
 */
final readonly class MaxLengthRule extends AbstractLengthRule
{
    public function __construct(
        private int $max,
        private string $code = 'too_long',
        private string $message = 'The field "{field}" must not exceed {max} characters.',
        bool $multibyte = true,
        string $encoding = 'UTF-8',
    ) {
        parent::__construct($multibyte, $encoding);
    }

    #[Override]
    protected function validate(mixed $value, FieldDefinition $field): array
    {
        if (!is_string($value) || $value === '') {
            return [];
        }

        $len = $this->getLength($value);
        if ($len > $this->max) {
            return [
                $this->makeError($field, $this->code, $this->message, [
                    'max' => $this->max,
                    'length' => $len,
                ]),
            ];
        }

        return [];
    }
}

Caractéristiques clés d’une règle :

  • Elle renvoie des erreurs structurées, pas des booléens ou des exceptions
  • Elle peut renvoyer plusieurs erreurs
  • Elle est déterministe et sans effets de bord

Utilisation :

$field = (new FieldDefinition('message'))
    ->addRule(new MaxLengthRule(500));

Écrire un filtre

Les filtres sont appliqués tôt dans le pipeline. Leur objectif est d’assainir ou de normaliser l’entrée brute afin que la validation en aval opère sur des données prévisibles.

Un exemple courant est la normalisation des numéros de téléphone.

<?php

declare(strict_types=1);

namespace FormToEmailFilter;

use FormToEmailCoreFieldDefinition;

/**
 * Removes any non-digit characters from phone number strings.
 * Keeps only 0–9 digits.
 */
final class SanitizePhoneFilter extends AbstractFilter
{
    #[Override]
    public function apply(mixed $value, FieldDefinition $field): mixed
    {
        if (!is_string($value)) {
            return $value;
        }

        return preg_replace('/D+/', '', $value) ?? '';
    }
}

Caractéristiques clés d’un filtre :

  • Il opère sur l’entrée brute
  • Il doit être sûr à appliquer avant validation
  • Il ne doit pas générer d’erreurs de validation

Utilisation :

$field = (new FieldDefinition('phone'))
    ->addFilter(new SanitizePhoneFilter());

Choisir le bon type de processeur

Une règle simple :

  • Filtre : nettoyer ou normaliser une entrée non fiable. Les filtres rendent une entrée dangereuse prévisible.
  • Règle : faire respecter une contrainte et signaler des erreurs. Les règles décident si les données sont acceptables.
  • Transformateur : produire une valeur finale et canonique. Les transformateurs rendent les données acceptées cohérentes

Si une logique modifie les données et peut échouer la validation, elle doit généralement être scindée en deux processeurs. Cette séparation maintient des pipelines prévisibles et faciles à raisonner.

En gardant les processeurs petits, explicites et locaux aux champs, Form-to-Email rend l’extension simple sans introduire de hooks globaux ni de comportement implicite.


Garanties de qualité et contraintes d’analyse statique

Cette bibliothèque a été écrite en partant du principe qu’elle sera lue, auditée et modifiée longtemps après sa création initiale. Pour un petit composant d’infrastructure, la justesse et la prévisibilité importent plus que la vitesse d’itération.

Les garanties ci-dessous ne sont pas aspiratoires. Elles sont appliquées en continu via l’outillage et la CI, et elles contraignent délibérément la manière dont le code peut évoluer.

DomaineOutilNiveau / politiqueObjectif
LangagePHP 8.4+declare(strict_types=1) partoutÉliminer la coercition implicite des types et les comportements ambigus
Analyse statiquePHPStanNiveau 8 (maximum)Détecter les erreurs de type, le code mort et les hypothèses invalides
Analyse statiquePsalmNiveau 2 (très strict)Imposer un typage précis et empêcher des flux non sûrs
TestsPHPUnit100% de couverture de lignesVerrouiller l’ordre d’exécution, les modes d’échec et les effets de bord
Style de codePHPCSPSR-12 + SlevomatAssurer la cohérence et des standards PHP modernes
CIGitHub ActionsObligatoire à chaque commitEmpêcher l’introduction de régressions dans main

L’analyse statique est traitée comme une contrainte de conception plutôt que comme un filet de sécurité. Lorsque les outils signalent un problème, l’implémentation est corrigée au lieu d’assouplir les règles. Cela oblige à traiter les cas limites tôt et empêche des classes entières d’erreurs d’exécution de se produire.

La couverture totale des tests n’est pas recherchée comme une métrique de vanité. Dans un système basé sur des pipelines, c’est le moyen le plus pratique de s’assurer que l’ordre des processeurs, le comportement de validation et les effets de bord terminaux restent stables dans le temps.

L’absence de couplage à un framework renforce encore ces garanties. Il n’y a pas de cycles de vie externes, d’abstractions de requête, ou de couches de configuration pouvant changer indépendamment. Ce que les outils d’analyse et la suite de tests valident est exactement ce qui s’exécute en production.

Ensemble, ces contraintes ralentissent volontairement le changement. Ajouter un nouveau comportement exige justification, typage, analyse et tests. Cette friction est voulue : elle maintient la bibliothèque petite, auditable et fiable en tant que composant de longue durée.

Quand cette approche est pertinente (et quand elle ne l’est pas)

Form-to-Email n’est pas une solution universelle. C’est une réponse délibérément étroite à un problème étroitement défini.

Cette approche a du sens quand la gestion de formulaires est une petite partie mais importante d’un système. Les cas typiques incluent des formulaires de contact, des outils internes, des endpoints de soumission à faible volume, ou des projets auto-hébergés où l’infrastructure est déjà sous contrôle. Dans ces situations, embarquer un framework complet ou déléguer les soumissions à un fournisseur externe ajoute souvent plus de surface que de valeur.

Elle a aussi du sens quand la prévisibilité compte plus que la commodité. Un pipeline strict, un ordre d’exécution explicite, et un modèle d’erreurs structurées rendent le comportement facile à comprendre et facile à tester, même des années plus tard.

Il existe des cas tout aussi clairs où cette bibliothèque n’est pas adaptée. Les applications avec des workflows complexes, de l’authentification, de la persistance, et une logique métier riche bénéficient d’un framework complet. Les formulaires publics à fort trafic exposés à des abus constants seront peut-être mieux servis par des services externes spécialisés dotés de mesures d’atténuation et de monitoring intégrés.

L’intention ici est la proportionnalité. PHP moderne est suffisamment robuste pour supporter de petits composants explicites, peu dépendants, sans sacrifier la sécurité. Quand le problème est simple, garder la solution simple est souvent le choix le plus robuste.

Dans ce sens, Form-to-Email est moins un produit réutilisable qu’une décision d’ingénierie documentée : rendre le flux de données explicite, garder les effets de bord isolés et éviter les abstractions inutiles.

Références et sources

La conception et les contraintes de cette bibliothèque s’appuient sur des standards existants, des outils et des bonnes pratiques. Les références suivantes fournissent du contexte et une justification pour de nombreuses décisions décrites dans cet article.

Langage PHP et RFC

Analyse statique et tests

Standards de codage

Email et sécurité

Liens du projet

18 janvier 2026 par Julien Turbide