J’ai glissé une faute de frappe dans un #[IsGranted] d’une application Symfony 7.4 : usr au lieu de user. Le cache:clear de production est passé au vert, la route a répondu 500 au premier appel. Le 2 novembre, Symfony a annoncé que la version 8.2, attendue ce mois-ci, compile les expressions de sécurité et vérifie aussi celles des attributs de contrôleur, avec une nouvelle commande lint:expressions utilisable en CI. Sur une application qui a quelques années, c’est le changement de 8.2 que je mettrais en premier dans le plan de montée. Mais il n’y a pas besoin d’attendre : une partie du contrôle existe déjà en 7.4, et l’autre partie tient en quarante lignes de PHP.
Ce que Symfony 8.2 change dans les expressions
Le composant ExpressionLanguage alimente access_control (l’option allow_if), #[IsGranted], #[Cache], #[IsCsrfTokenValid] et les gardes de workflow. Le billet du 2 novembre annonce trois changements, tous fusionnés dans la branche 8.2.
D’abord, les expressions sont compilées en PHP au warmup du cache. Selon le billet, leur évaluation passe d’environ 15 à 30 µs à environ 1 µs, sans rien changer dans l’application. Ensuite, le warmup échoue si une règle d’access_control ou une garde de workflow contient une expression invalide, variable ou fonction inconnue comprise (PR #66664). Enfin, php bin/console lint:expressions vérifie ces mêmes expressions et celles des attributs de contrôleur. Contrairement au warmup, la commande tourne en mode debug, donc à côté de lint:yaml et lint:container dans votre pipeline.
Le troisième changement touche constant() et enum(), j’y reviens plus bas. Et le billet du 28 octobre ouvre is_granted() et la nouvelle fonction current_user() aux contraintes de validation Expression et When, ce qui multiplie les expressions à surveiller.
Le gain de performance est réel, mais à l’échelle d’une requête qui passe par Doctrine, quelques dizaines de microsecondes ne se verront pas. Le gain qui compte est l’autre : une erreur d’autorisation détectée avant le déploiement plutôt que par un utilisateur.
Ce que votre 7.4 attrape déjà
J’ai d’abord vérifié la thèse que j’avais en tête, à savoir qu’une faute dans access_control ne se voit qu’en production. Elle est fausse. En 7.4, le SecurityBundle déclare un ExpressionCacheWarmer qui analyse chaque allow_if avec la liste des variables autorisées. Avec ces deux règles :
# config/packages/security.yaml
security:
access_control:
- { path: ^/factures, allow_if: "is_granted('ROLE_COMPTA') or usr.isOwner()" }
- { path: ^/rapports, allow_if: "is_granted('ROLE_DIRECTION') and is_grnted('ROLE_EXPORT')" }
le cache:clear échoue, en dev comme en prod :
In Parser.php line 278:
Variable "usr" is not valid around position 30 for expression `is_granted('
ROLE_COMPTA') or usr.isOwner()`. Did you mean "user"?
Il y a une exception, et elle se rencontre souvent dans les scripts de déploiement anciens : ce warmer est déclaré optionnel. Avec cache:clear --no-optional-warmers, ou un --no-warmup suivi d’un warmup partiel, la même configuration passe au vert. Si votre Dockerfile ou votre recette Deployer contient l’une de ces options, vos allow_if ne sont vérifiés par personne.
Ces commandes ont été exécutées ce matin sur un squelette Symfony 7.4.22 (PHP 8.3) avec SecurityBundle, ainsi que tous les extraits qui suivent.
Ce qui passe au travers : les attributs de contrôleur
Le contrôleur suivant contient la même faute de frappe :
#[Route('/devis/{id}')]
#[IsGranted(new Expression('is_granted("ROLE_COMPTA") or usr === subject'), subject: 'id')]
public function show(int $id): Response
En 7.4, cache:clear en prod répond « successfully cleared ». Le premier GET /devis/42 renvoie une 500, avec dans les logs une SyntaxError sur la variable usr. Le warmer ne lit que les expressions déclarées dans la configuration de sécurité ; celles des attributs ne sont analysées qu’à leur première évaluation. Et comme l’expression entière est analysée avant d’être évaluée, la route est cassée pour tout le monde, y compris les comptables.
Sur une application récente bien couverte par des tests fonctionnels, la faute sort au premier test. Sur un legacy, c’est différent : les écrans d’administration rarement ouverts sont justement ceux qui portent les règles d’accès les plus tordues et le moins de tests. Les gardes de workflow sont dans le même cas : le billet précise qu’elles n’étaient pas mises en cache du tout avant 8.2.
Combler le trou avant la montée en 8.2
ExpressionLanguage::lint() existe depuis longtemps et produit exactement le message de lint:expressions. Il suffit de lui donner les expressions des attributs. Ce script démarre le kernel, parcourt les routes, lit les #[IsGranted] de la classe et de la méthode, et vérifie chaque Expression avec les variables que le voter d’expressions fournit :
<?php
// tools/lint-is-granted.php : lint des expressions de #[IsGranted] (Symfony 6.4 et 7.x)
use App\Kernel;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\ExpressionLanguage\SyntaxError;
use Symfony\Component\Security\Core\Authorization\ExpressionLanguage;
use Symfony\Component\Security\Http\Attribute\IsGranted;
require dirname(__DIR__).'/vendor/autoload.php';
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
$kernel = new Kernel('dev', true);
$kernel->boot();
$routes = $kernel->getContainer()->get('router')->getRouteCollection();
$langage = new ExpressionLanguage();
$variables = ['token', 'user', 'object', 'subject', 'role_names', 'auth_checker', 'trust_resolver'];
$erreurs = 0;
foreach ($routes as $nom => $route) {
$controleur = $route->getDefault('_controller');
if (!is_string($controleur) || !str_contains($controleur, '::')) {
continue;
}
[$classe, $methode] = explode('::', $controleur, 2);
if (!method_exists($classe, $methode)) {
continue;
}
foreach ([new ReflectionClass($classe), new ReflectionMethod($classe, $methode)] as $cible) {
foreach ($cible->getAttributes(IsGranted::class) as $attribut) {
$expression = $attribut->newInstance()->attribute;
if (!$expression instanceof Expression) {
continue;
}
try {
$langage->lint($expression, $variables);
} catch (SyntaxError $e) {
++$erreurs;
printf("%s (%s) : %s\n", $nom, $controleur, $e->getMessage());
}
}
}
}
exit($erreurs > 0 ? 1 : 0);
Sur le squelette de test, il sort avec le code 1 et le message attendu :
app_facture_show (App\Controller\FactureController::show) : Variable "usr" is not valid
around position 30 for expression `is_granted("ROLE_COMPTA") or usr === subject`.
Did you mean "user"?
La liste des variables vient de ExpressionVoter::getVariables() dans security-core 7.4 ; vérifiez-la dans votre version avant de copier le script. Placez le script dans la CI juste après composer install, et supprimez-le le jour où vous passez en 8.2 : lint:expressions le remplace, et couvre en plus #[Cache], #[IsCsrfTokenValid] et les gardes.
constant() et enum() : la dépréciation à repérer
Le dernier changement est plus discret. Aujourd’hui, une expression peut lire n’importe quelle constante de l’application. Sur la même 7.4 :
define('DB_PASSWORD', 's3cr3t');
var_dump((new ExpressionLanguage())->evaluate('constant("DB_PASSWORD")'));
// string(6) "s3cr3t"
Tant que les expressions sont écrites par vos développeurs, c’est sans conséquence. Le jour où elles viennent d’un back-office (règles de remise, filtres de segmentation, workflow paramétrable), c’est une fuite possible. 8.2 ajoute ConstantFunctionProvider, qui restreint constant() et enum() à une liste blanche avec jokers, et déprécie leur usage sans restriction (PR #66630). En 8.2, les instances créées par Symfony autorisent déjà tout, donc l’avertissement ne concerne que le code qui instancie son propre ExpressionLanguage : le vôtre, et celui de vos bundles.
grep -rn --include='*.php' 'new ExpressionLanguage' src/ vendor/ | grep -v '/symfony/'
Chaque résultat est une instance à configurer en 8.2. C’est aussi l’inventaire des endroits où des expressions sont évaluées hors de la sécurité, ce qui mérite une relecture : l’analyseur d’expressions a d’ailleurs fait l’objet d’un avis de déni de service le 15 octobre, au correctif incomplet, que j’évoquais dans l’article sur les correctifs Symfony et la version citée par l’avis.
Les limites
Le script ne voit que #[IsGranted] sur des contrôleurs déclarés en Classe::méthode. Il ignore les contrôleurs invocables, #[Cache], les gardes de workflow et les expressions passées à isGranted() dans le code. Il utilise l’ExpressionLanguage de sécurité par défaut : si vous enregistrez vos propres fonctions par un fournisseur, il les signalera comme inconnues, et il faudra lui ajouter ce fournisseur. Enfin, le lint attrape les fautes de syntaxe et de nom, pas les erreurs de logique : un or à la place d’un and reste invisible.
Côté 8.2, le billet du 28 octobre prévient que is_granted() dans une contrainte When lève une exception quand l’objet est validé hors requête, dans une commande ou un worker Messenger. C’est voulu, pour éviter qu’une contrainte soit sautée en silence, mais un DTO partagé entre un contrôleur et un worker cassera au premier message traité. Testez ce chemin avant d’adopter la fonction.
Cet article s’appuie sur les billets et les pull requests de Symfony pour la partie 8.2 ; seuls les comportements de la 7.4 ont été reproduits.
Concrètement, par où commencer
- Cherchez
--no-optional-warmerset--no-warmupdans vos Dockerfile, Makefile et recettes de déploiement, et vérifiez qu’un warmup complet tourne quelque part avant la mise en production. - Lancez le script ci-dessus une fois sur votre application, avant même de l’ajouter à la CI : le premier passage dit si le risque est théorique.
- Comptez vos
new ExpressionLanguagehors de Symfony, et notez pour chacun qui écrit les expressions qu’il évalue. - Dans le plan de montée en 8.2, mettez
lint:expressionsdans le premier lot, avant toute autre nouveauté.
Les contraintes de validation pourront bientôt appeler is_granted() : l’autorisation sort du pare-feu, et c’est la prochaine frontière qu’un audit de legacy devra suivre.
Sources
- New in Symfony 8.2: Validated and Compiled Expressions, Symfony Blog, 2 novembre 2026
- New in Symfony 8.2: Security Expressions Everywhere, Symfony Blog, 28 octobre 2026
- symfony/symfony PR #66664, GitHub, fusionnée le 7 octobre 2026
- symfony/symfony PR #66630, GitHub, fusionnée le 6 octobre 2026
- SecurityBundle 7.4, ExpressionCacheWarmer.php, GitHub, consulté le 4 novembre 2026
Sur ce sujet, l’offre de l’atelier : Migration Symfony