OptionsResolver — le contrat d'entrée de ta classe
Valider, normaliser et documenter les options d'une classe en un seul endroit — c'est le rôle d'OptionsResolver.
Imagine une classe Mailer qui accepte un tableau d'options : host, port,
timeout, encryption… Sans outillage, tu te retrouves à écrire à la main
une dizaine de lignes if (!isset($options['host'])) throw …,
puis des vérifications de type, puis des valeurs par défaut — du code répétitif, fragile et sans documentation.
Composant Symfony (
symfony/options-resolver) qui centralise la définition,
la validation et la normalisation des options d'une classe.
Un seul appel à resolve(array $options) valide tout et retourne un tableau propre.
Le pattern consiste à déclarer les options dans une méthode configureOptions()
(ou similaire), puis à appeler resolve() dans le constructeur ou dans une factory.
Le composant est utilisé en interne par tous les composants Symfony qui acceptent des options :
Form, HttpClient, Messenger, etc.
Visualise le pipeline — essaie différentes valeurs pour observer les étapes de validation et normalisation :
Lance resolve() pour voir les étapes.
Les avantages clés par rapport à une validation manuelle :
| Aspect | Validation manuelle | OptionsResolver |
|---|---|---|
| Options inconnues | Silencieuses | UndefinedOptionsException immédiate |
| Options manquantes | Notice / undefined key | MissingOptionsException explicite |
| Type erroné | À coder à la main | setAllowedTypes() — une ligne |
| Documentation | Dans un commentaire | setInfo() — incluse dans les erreurs |
| Déprécation | À coder manuellement | setDeprecated() — un appel |
Définir et résoudre — les fondations
setDefault, setRequired, setDefined — définir les options. resolve() — les valider toutes d'un coup.
Tout commence par un objet OptionsResolver dans lequel tu déclares tes options
avant d'appeler resolve(). Pense-y comme à un contrat signé avant que des données
n'entrent dans ta classe.
setDefined() — l'option est acceptée si fournie, mais pas obligatoire et sans valeur par défaut.setRequired() — l'option est obligatoire ; resolve() lève MissingOptionsException si absente.setDefault() — l'option est facultative avec une valeur de secours si non fournie.
use Symfony\Component\OptionsResolver\OptionsResolver; class HttpClient { public function __construct(array $options = []) { $resolver = new OptionsResolver(); $this->configureOptions($resolver); $this->options = $resolver->resolve($options); } private function configureOptions(OptionsResolver $resolver): void { // Obligatoire — MissingOptionsException si absent $resolver->setRequired('host'); // Facultatif avec valeur par défaut $resolver->setDefault('port', 80); $resolver->setDefault('timeout', 30); // Défini mais sans valeur — acceptable si fourni, pas obligatoire $resolver->setDefined(['proxy', 'auth_bearer']); } } // Utilisation new HttpClient(['host' => 'api.example.com', 'port' => 443]); // MissingOptionsException — 'host' est required new HttpClient(['port' => 443]); // UndefinedOptionsException — 'verify_ssl' n'est pas déclaré new HttpClient(['host' => 'api.example.com', 'verify_ssl' => true]);
setDefaults() est un raccourci pour définir plusieurs options par défaut en une seule fois —
exactement comme appeler setDefault() en boucle.
$resolver->setDefaults([ 'port' => 80, 'timeout' => 30, 'retries' => 3, ]);
setDefined() sans
setDefault(), l'option n'apparaît pas dans le tableau résolu quand elle n'est pas fournie.
Avec setDefault(), elle est toujours présente avec sa valeur de secours.
La méthode setIgnoreUndefined(true) permet d'assouplir le contrat : les options inconnues
sont silencieusement ignorées plutôt que de lever une exception.
À utiliser avec parcimonie — en général pour des sous-composants qui acceptent un tableau d'options
plus large que ce qu'ils consomment.
Validation — types et valeurs autorisées
setAllowedTypes filtre par type PHP ou FQCN. setAllowedValues accepte des valeurs statiques ou une closure.
Une option définie mais sans contrainte accepte n'importe quelle valeur — ce qui n'est souvent
pas ce que tu veux. setAllowedTypes() et setAllowedValues() verrouillent
le contrat.
Valide le type PHP de la valeur. Accepte les types scalaires natifs (
'string', 'int', 'bool', 'float', 'array', 'null'),
'callable', 'object', ou un FQCN (DateTimeInterface::class).
Pour un tableau de strings : 'string[]'.
Valide la valeur elle-même. Accepte une liste statique (enum de chaînes) ou une closure
function(mixed $value): bool. Retourner false déclenche
InvalidOptionsException.
$resolver // Types : string ou null (option nullable) ->setAllowedTypes('host', ['string', 'null']) // Type entier uniquement ->setAllowedTypes('port', 'int') // Valeur dans une liste finie ->setAllowedValues('encryption', ['tls', 'ssl', null]) // Validation custom : port dans la plage TCP valide ->setAllowedValues('port', function(int $port): bool { return $port >= 1 && $port <= 65535; }) // Tableau de strings : FQCN[] ->setAllowedTypes('middlewares', 'string[]') // Instance d'une interface ->setAllowedTypes('logger', ['null', \Psr\Log\LoggerInterface::class]);
L'ordre de validation est fixe : les types sont vérifiés avant les valeurs.
Si port reçoit "abc" (string), la validation de type échoue
avant même d'atteindre la closure de plage.
Cela signifie que dans ta closure setAllowedValues, tu peux supposer que le type est correct.
// FQCN — accepte une instance ou null $resolver->setDefault('transport', null); $resolver->setAllowedTypes('transport', ['null', TransportInterface::class]); // Enum PHP natif : passe directement l'instance $resolver->setAllowedValues('level', LogLevel::cases());
| Type string | Correspondance PHP |
|---|---|
'string' | is_string() |
'int' | is_int() |
'bool' | is_bool() |
'float' | is_float() |
'array' | is_array() |
'null' | is_null() |
'callable' | is_callable() |
'object' | is_object() |
'string[]' | array de strings |
| FQCN | instanceof |
Normalizers — transformer les valeurs après validation
Les normalizers s'exécutent après la validation — ils transforment sans valider. setNormalizer remplace, addNormalizer enchaîne.
La validation décide si une valeur est acceptable. La normalisation décide de sa forme définitive. Les deux étapes sont séparées : les normalizers s'exécutent après la validation des types et des valeurs.
Remplace tous les normalizers précédents pour cette option. La closure reçoit
Options $options (le resolver en lecture) et $value.
Elle retourne la valeur transformée.
Ajoute un normalizer à la chaîne sans effacer les précédents. Le paramètre
$forcePrepend = true insère en tête plutôt qu'en queue.
$resolver->setDefault('host', 'localhost'); // Premier normalizer : trim des espaces $resolver->setNormalizer('host', function(\Symfony\Options $options, string $value): string { return trim($value); }); // Deuxième normalizer (enchaîné) : lowercase $resolver->addNormalizer('host', function(\Symfony\Options $options, string $value): string { return strtolower($value); }); // ' MyHost ' → 'myhost' $resolved = $resolver->resolve(['host' => ' MyHost ']);
Le paramètre Options $options donne accès à d'autres options déjà résolues —
pratique pour construire une valeur composite.
// Normalizer qui lit une autre option $resolver->addNormalizer('base_url', function(\Symfony\Options $options, string $value): string { if ($options['port'] !== 80) { return $value . ':' . $options['port']; } return $value; });
setNormalizer() après
addNormalizer() sur la même option efface les normalizers ajoutés.
Si tu étends un resolver existant, préfère toujours addNormalizer().
Chaîne de normalizers — clique sur un normalizer pour l'ajouter, puis lance :
Lazy options — valeurs calculées à la demande
Une closure dans setDefault n'est évaluée qu'à la résolution — elle peut lire d'autres options déjà résolues.
Imagine que ta valeur par défaut n'est pas une constante, mais dépend d'une autre option — ou d'un calcul coûteux que tu ne veux exécuter que si l'option n'est pas fournie. C'est exactement le rôle des lazy options.
Passer une
Closure à setDefault(). La closure est évaluée
seulement lors de la résolution — jamais à la déclaration. Signature minimale :
function(Options $options): mixed.
use Symfony\Component\OptionsResolver\Options; $resolver->setDefault('host', 'localhost'); $resolver->setDefault('port', 3306); // dsn est calculé à partir de host et port déjà résolus $resolver->setDefault('dsn', function(Options $options): string { return 'mysql://' . $options['host'] . ':' . $options['port']; }); // Si port n'est pas fourni, dsn utilise 3306 // Si port est fourni (ex. 5432), dsn le reflète automatiquement
La signature longue — avec un second paramètre $previousValue — permet de
chaîner avec la valeur fournie par l'appelant : la closure reçoit la valeur passée
dans resolve() et peut la modifier ou la remplacer.
// Signature longue : accès à la valeur fournie par l'appelant $resolver->setDefault('tags', function(Options $options, ?array $previous): array { $base = ['app']; return array_merge($base, $previous ?? []); }); // Sans fournir 'tags' → ['app'] // Avec ['tags' => ['feature']] → ['app', 'feature']
OptionDefinitionException au moment de la résolution.
Le message indique la chaîne de dépendances impliquée.
resolve(). Ce qui signifie qu'un calcul coûteux (requête base
de données, lecture de fichier) dans un lazy default est court-circuité dès que l'appelant
passe la valeur.
Les lazy options peuvent accéder à d'autres lazy options — le resolver gère automatiquement
l'ordre d'évaluation en construisant le graphe de dépendances. En revanche, modifier le resolver
depuis l'intérieur d'une lazy closure est interdit et lève AccessException.
Options imbriquées et prototypes
setOptions() crée un sous-resolver pour une option tableau. setPrototype(true) applique le même schéma à chaque élément d'un array.
Pour des options qui sont elles-mêmes des tableaux structurés — configuration d'une connexion,
d'un transport, d'un serveur — setOptions() crée un sous-resolver
dédié plutôt que d'imbriquer des if/isset en cascade.
La closure reçoit un
OptionsResolver vierge pour le sous-tableau,
plus un objet Options $parent donnant accès aux options du niveau supérieur.
Le sous-resolver est configuré exactement comme un resolver racine.
$resolver->setOptions('database', function(OptionsResolver $sub, Options $parent): void { $sub->setDefault('host', 'localhost'); $sub->setRequired('name'); $sub->setDefault('port', function(Options $subOpts): int { // Accède au parent pour choisir le port par défaut return $parent['env'] === 'test' ? 5432 : 3306; }); $sub->setAllowedTypes('port', 'int'); }); // Utilisation $resolver->resolve([ 'env' => 'prod', 'database' => ['name' => 'my_db', 'host' => 'db.example.com'], ]);
Quand l'option est un tableau dont chaque élément doit respecter le même schéma, activer le mode prototype. Le sous-resolver est appliqué à chaque entrée du tableau, avec son index comme clé de contexte dans les messages d'erreur.
$resolver->setOptions('servers', function(OptionsResolver $sub): void { $sub->setPrototype(true); // active le mode prototype $sub->setRequired('hostname'); $sub->setDefault('port', 80); $sub->setAllowedTypes('port', 'int'); }); // Chaque élément est résolu séparément $resolver->resolve([ 'servers' => [ ['hostname' => 'web1.example.com'], ['hostname' => 'web2.example.com', 'port' => 8080], ], ]);
setOptions() seul
L'option est un unique tableau structuré. Exemple : database, cache, transport.
setOptions() + setPrototype(true)
L'option est une liste de tableaux ayant le même schéma. Exemple : servers[], middlewares[].
servers[1].port reçoit
une chaîne, l'exception indiquera The option "servers[1][port]" … — très pratique
pour déboguer des listes longues.
Déprecation et documentation des options
setDeprecated() émet un warning à l'utilisation. setInfo() documente l'option dans les messages d'erreur.
Au fil du temps, certaines options deviennent obsolètes ou changent de nom.
setDeprecated() émet un E_USER_DEPRECATED dès que l'option
est utilisée — sans casser le code existant.
setInfo() ajoute une phrase d'explication dans les messages d'erreur.
Signature :
setDeprecated(string $option, string $package, string $version, string|Closure $message).Le
$package est le nom Composer du paquet qui déprécie l'option.
Le $version est la version où la déprécation a été introduite.
// Déprecation simple — message statique $resolver->setDeprecated( 'timeout', 'acme/http-client', '2.0', 'L\'option "timeout" est dépréciée depuis 2.0, utilise "connect_timeout" et "read_timeout".' ); // Déprecation conditionnelle — closure // Retourner '' désactive la déprecation pour cette valeur $resolver->setDeprecated( 'encryption', 'acme/mailer', '3.0', function(Options $options, mixed $value): string { if ($value === 'ssl') { return 'La valeur "ssl" est obsolète, utilise "tls".'; } return ''; // pas de warning pour les autres valeurs } );
Ajoute une ligne de documentation affichée dans le message de l'exception quand l'option est invalide. Idéal pour guider l'utilisateur vers la bonne valeur.
$resolver->setInfo('log_level', 'Niveau PSR-3. Valeurs : debug, info, notice, warning, error, critical, alert, emergency.'); // Si log_level reçoit 'verbose', l'exception inclura : // Hint: Niveau PSR-3. Valeurs : debug, info, notice, …
isDeprecated(string $option) permet de vérifier depuis l'extérieur si une option
est marquée comme dépréciée — utile dans les outils de debug ou les tests qui
veulent s'assurer qu'aucune option dépréciée n'est utilisée.
setDeprecated() + un normalizer qui recopie sa valeur dans la nouvelle option.
L'ancien code continue de fonctionner, mais reçoit un warning clair.
// Ancienne option 'timeout' renommée en 'connect_timeout' $resolver->setDefined('timeout'); $resolver->setDefault('connect_timeout', 30); $resolver->setDeprecated('timeout', 'acme/http', '2.0', 'Utilise "connect_timeout".'); // Recopie automatique dans la nouvelle option $resolver->setNormalizer('connect_timeout', function(Options $options, int $value): int { if (isset($options['timeout'])) { return $options['timeout']; } return $value; });
define() — l'interface fluente
define() retourne un OptionConfigurator qui chaîne toutes les méthodes de configuration en une seule expression.
Toutes les méthodes vues jusqu'ici (setDefault(), setAllowedTypes(),
setAllowedValues(), setNormalizer(), setDeprecated(),
setInfo()) peuvent être remplacées par une interface fluente via define().
Retourne un
OptionConfigurator qui expose en méthodes chaînables toutes les
configurations d'une option. L'option est déclarée implicitement — pas besoin d'appeler
setDefined() avant.
// Style classique $resolver->setRequired('host'); $resolver->setAllowedTypes('host', 'string'); $resolver->setInfo('host', 'Nom d\'hôte ou IP du serveur cible.'); // Style fluent — équivalent exact $resolver->define('host') ->required() ->allowedTypes('string') ->info('Nom d\'hôte ou IP du serveur cible.');
L'interface fluente est particulièrement lisible pour les options avec plusieurs contraintes.
Voici un exemple complet de configureOptions() utilisant uniquement define() :
private function configureOptions(OptionsResolver $resolver): void { $resolver->define('host') ->required() ->allowedTypes('string') ->normalize(fn(Options $o, string $v) => strtolower(trim($v))) ->info('Adresse DNS ou IP du serveur.'); $resolver->define('port') ->default(80) ->allowedTypes('int') ->allowedValues(fn(int $p) => $p >= 1 && $p <= 65535) ->info('Port TCP (1-65535).'); $resolver->define('encryption') ->default(null) ->allowedValues([null, 'tls', 'ssl']) ->deprecated('ssl', 'acme/mailer', '3.0', 'Utilise "tls".') ->info('Protocole de chiffrement. null = pas de chiffrement.'); }
| Méthode standalone | Méthode OptionConfigurator |
|---|---|
setDefault($opt, $val) | ->default($val) |
setRequired($opt) | ->required() |
setAllowedTypes($opt, $types) | ->allowedTypes($types) |
setAllowedValues($opt, $vals) | ->allowedValues($vals) |
setNormalizer($opt, $fn) | ->normalize($fn) |
setDeprecated($opt, ...) | ->deprecated(...) |
setInfo($opt, $msg) | ->info($msg) |
setOptions($opt, $fn) | ->options($fn) |
define('host') deux fois sur le même
resolver lève OptionDefinitionException. Si tu veux reconfigurer une option
déjà définie (dans une sous-classe), utilise les méthodes standalone qui permettent
l'overriding partiel.
Exceptions — comprendre chaque erreur
Six exceptions, six situations — comprendre laquelle arrive quand permet de diagnostiquer instantanément.
OptionsResolver lève six types d'exceptions. Chacune correspond à une situation précise — les identifier instantanément évite de fouiller dans la stack trace.
| Exception | Quand | Exemple de déclencheur |
|---|---|---|
UndefinedOptionsException |
Option inconnue passée à resolve() |
resolve(['verify_ssl' => true]) alors que verify_ssl n'est pas déclarée |
MissingOptionsException |
Option required absente de resolve() |
setRequired('host') + resolve([]) |
InvalidOptionsException |
Type ou valeur invalide | setAllowedTypes('port','int') + resolve(['port' => 'abc']) |
OptionDefinitionException |
Dépendance circulaire entre lazy ou define() doublon |
Option A lazy lit B, option B lazy lit A |
AccessException |
Modification du resolver après verrouillage (dans une lazy/normalizer) | $resolver->setDefault(…) depuis l'intérieur d'un normalizer |
NoSuchOptionException |
Accès lazy à une option non définie via $options['x'] |
Faute de frappe dans le nom d'option lu dans une lazy closure |
Tu peux attraper
ExceptionInterface pour gérer toutes les erreurs
OptionsResolver en un seul catch, ou attraper chaque exception
individuellement pour une réponse différenciée.
use Symfony\Component\OptionsResolver\Exception\ExceptionInterface; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; try { $resolved = $resolver->resolve($options); } catch (MissingOptionsException $e) { // Champ obligatoire absent — feedback utilisateur throw new \InvalidArgumentException('Configuration incomplète : ' . $e->getMessage()); } catch (InvalidOptionsException $e) { // Valeur incorrecte — logguer et remonter $this->logger->error('Options invalides', ['error' => $e->getMessage()]); throw $e; } catch (ExceptionInterface $e) { // Toutes les autres erreurs OptionsResolver throw $e; }
InvalidOptionsException liste les
valeurs acceptées et, si setInfo() a été appelé, ajoute le hint.
UndefinedOptionsException suggère les options proches via la distance de Levenshtein —
une faute de frappe donne un message du type "Did you mean …?".
Synthèse — guide de construction d'un resolver
Ordre de construction, guide de choix entre setNormalizer/setAllowedValues/lazy — tout en un tableau.
Voici un récapitulatif de tout ce que tu peux faire avec OptionsResolver, présenté dans l'ordre logique de construction d'un resolver robuste.
| Étape | Méthode(s) | Quand l'utiliser |
|---|---|---|
| 1. Déclarer les options | setDefined() / setRequired() / setDefault() |
Toujours — c'est la base du contrat |
| 2. Valider les types | setAllowedTypes() |
Dès qu'une option a un type précis (toujours, en pratique) |
| 3. Valider les valeurs | setAllowedValues() |
Enum, plages, expressions régulières, validation métier |
| 4. Normaliser | setNormalizer() / addNormalizer() |
Trim, lowercase, cast, construction d'objet à partir de scalaires |
| 5. Valeurs calculées | setDefault(Closure) — lazy |
Valeur dérivée d'une autre option, calcul coûteux optionnel |
| 6. Structure profonde | setOptions() + setPrototype() |
Option qui est elle-même un tableau structuré ou une liste |
| 7. Documenter | setInfo() |
Toujours — enrichit les messages d'erreur pour les utilisateurs |
| 8. Déprécier | setDeprecated() |
Renommage ou suppression progressive d'une option |
setAllowedValues — pour rejeter une valeur invalide.setNormalizer — pour transformer une valeur valide en une forme canonique.setDefault(Closure) — pour calculer une valeur par défaut dépendant du contexte.
Un pattern courant dans les composants Symfony est d'utiliser configureOptions()
comme seul point de configuration, et de l'appeler dans le constructeur.
Les sous-classes peuvent appeler parent::configureOptions($resolver) puis
ajouter leurs propres options avec addAllowedTypes() ou addNormalizer()
sans remplacer ce qu'a fait le parent.
class BaseTransport { protected function configureOptions(OptionsResolver $resolver): void { $resolver->define('timeout')->default(30)->allowedTypes('int'); } } class SslTransport extends BaseTransport { protected function configureOptions(OptionsResolver $resolver): void { parent::configureOptions($resolver); $resolver->define('verify_peer')->default(true)->allowedTypes('bool'); $resolver->define('ca_file')->default(null)->allowedTypes(['null', 'string']); } }
composer require symfony/options-resolver.
Le composant est autonome — pas besoin du framework complet.
Il est inclus automatiquement dans les projets Symfony via symfony/symfony.
Applique OptionsResolver dans tes propres classes de configuration — remplace tes tableaux d'options validés à la main par un resolver dédié.