← Tous les cours / Symfony OptionsResolver Cours
00 — POURQUOI OPTIONSRESOLVER ?

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.

OptionsResolver
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 :

AspectValidation manuelleOptionsResolver
Options inconnuesSilencieusesUndefinedOptionsException immédiate
Options manquantesNotice / undefined keyMissingOptionsException explicite
Type erronéÀ coder à la mainsetAllowedTypes() — une ligne
DocumentationDans un commentairesetInfo() — incluse dans les erreurs
DéprécationÀ coder manuellementsetDeprecated() — un appel
01 — DÉFINIR ET RÉSOUDRE

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.

Les trois états d'une option
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 vs setDefault : si tu utilises 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.

02 — VALIDATION TYPES ET VALEURS

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.

setAllowedTypes()
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[]'.
setAllowedValues()
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]);
⚠️ addAllowedTypes / addAllowedValues : ces variantes ajoutent des types ou valeurs supplémentaires sans remplacer ceux déjà définis. Utile quand tu étends un resolver existant dans une sous-classe.

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 stringCorrespondance 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
FQCNinstanceof
03 — NORMALIZERS

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.

setNormalizer()
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.
addNormalizer()
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 vs addNormalizer : appeler 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 :

04 — LAZY OPTIONS

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.

Lazy option
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']
⚠️ Dépendance circulaire : si l'option A dépend de B et B dépend de A, OptionsResolver lève OptionDefinitionException au moment de la résolution. Le message indique la chaîne de dépendances impliquée.
💡 Évaluation paresseuse : la closure n'est pas appelée si l'option est fournie explicitement dans 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.

05 — OPTIONS IMBRIQUÉES

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.

setOptions() — options imbriquées
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'],
]);
setPrototype(true) — tableau de structures identiques
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[].

💡 Message d'erreur précis : en mode prototype, si 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.
06 — DÉPRECATION ET DOCUMENTATION

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.

setDeprecated()
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
    }
);
setInfo()
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.

💡 Bonne pratique : quand tu renommes une option, garde l'ancienne avec 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;
});
07 — INTERFACE FLUENTE DEFINE()

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().

define(string $option)
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 standaloneMé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)
⚠️ Double définition : appeler 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.
08 — EXCEPTIONS

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
Toutes héritent de ExceptionInterface
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;
}
💡 Messages d'erreur enrichis : 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 …?".
09 — SYNTHÈSE

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.

ÉtapeMé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
Guide de choix : setAllowedValues vs normalizer vs lazy
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']);
    }
}
💡 Installation : 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.
Prochaine étape
Applique OptionsResolver dans tes propres classes de configuration — remplace tes tableaux d'options validés à la main par un resolver dédié.