The OptionsResolver Component

The OptionsResolver component is an improved replacement for thearray_replace PHP function. It allows you to create anoptions system with required options, defaults, validation (type, value),normalization and more.

Installation

  1. $ composer require symfony/options-resolver

Note

If you install this component outside of a Symfony application, you mustrequire the vendor/autoload.php file in your code to enable the classautoloading mechanism provided by Composer. Readthis article for more details.

Usage

Imagine you have a Mailer class which has four options: host,username, password and port:

  1. class Mailer
  2. {
  3. protected $options;
  4.  
  5. public function __construct(array $options = [])
  6. {
  7. $this->options = $options;
  8. }
  9. }

When accessing the $options, you need to add some boilerplate code tocheck which options are set:

  1. class Mailer
  2. {
  3. // ...
  4. public function sendMail($from, $to)
  5. {
  6. $mail = ...;
  7.  
  8. $mail->setHost($this->options['host'] ?? 'smtp.example.org');
  9. $mail->setUsername($this->options['username'] ?? 'user');
  10. $mail->setPassword($this->options['password'] ?? 'pa$$word');
  11. $mail->setPort($this->options['port'] ?? 25);
  12.  
  13. // ...
  14. }
  15. }

Also, the default values of the options are buried in the business logic of yourcode. Use the array_replace to fix that:

  1. class Mailer
  2. {
  3. // ...
  4.  
  5. public function __construct(array $options = [])
  6. {
  7. $this->options = array_replace([
  8. 'host' => 'smtp.example.org',
  9. 'username' => 'user',
  10. 'password' => 'pa$$word',
  11. 'port' => 25,
  12. ], $options);
  13. }
  14. }

Now all four options are guaranteed to be set, but you could still make an errorlike the following when using the Mailer class:

  1. $mailer = new Mailer([
  2. 'usernme' => 'johndoe', // 'username' is wrongly spelled as 'usernme'
  3. ]);

No error will be shown. In the best case, the bug will appear during testing,but the developer will spend time looking for the problem. In the worst case,the bug might not appear until it's deployed to the live system.

Fortunately, the OptionsResolverclass helps you to fix this problem:

  1. use Symfony\Component\OptionsResolver\OptionsResolver;
  2.  
  3. class Mailer
  4. {
  5. // ...
  6.  
  7. public function __construct(array $options = [])
  8. {
  9. $resolver = new OptionsResolver();
  10. $resolver->setDefaults([
  11. 'host' => 'smtp.example.org',
  12. 'username' => 'user',
  13. 'password' => 'pa$$word',
  14. 'port' => 25,
  15. ]);
  16.  
  17. $this->options = $resolver->resolve($options);
  18. }
  19. }

Like before, all options will be guaranteed to be set. Additionally, anUndefinedOptionsExceptionis thrown if an unknown option is passed:

  1. $mailer = new Mailer([
  2. 'usernme' => 'johndoe',
  3. ]);
  4.  
  5. // UndefinedOptionsException: The option "usernme" does not exist.
  6. // Defined options are: "host", "password", "port", "username"

The rest of your code can access the values of the options without boilerplatecode:

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5.  
  6. public function sendMail($from, $to)
  7. {
  8. $mail = ...;
  9. $mail->setHost($this->options['host']);
  10. $mail->setUsername($this->options['username']);
  11. $mail->setPassword($this->options['password']);
  12. $mail->setPort($this->options['port']);
  13. // ...
  14. }
  15. }

It's a good practice to split the option configuration into a separate method:

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5.  
  6. public function __construct(array $options = [])
  7. {
  8. $resolver = new OptionsResolver();
  9. $this->configureOptions($resolver);
  10.  
  11. $this->options = $resolver->resolve($options);
  12. }
  13.  
  14. public function configureOptions(OptionsResolver $resolver)
  15. {
  16. $resolver->setDefaults([
  17. 'host' => 'smtp.example.org',
  18. 'username' => 'user',
  19. 'password' => 'pa$$word',
  20. 'port' => 25,
  21. 'encryption' => null,
  22. ]);
  23. }
  24. }

First, your code becomes easier to read, especially if the constructor does morethan processing options. Second, sub-classes may now override theconfigureOptions() method to adjust the configuration of the options:

  1. // ...
  2. class GoogleMailer extends Mailer
  3. {
  4. public function configureOptions(OptionsResolver $resolver)
  5. {
  6. parent::configureOptions($resolver);
  7.  
  8. $resolver->setDefaults([
  9. 'host' => 'smtp.google.com',
  10. 'encryption' => 'ssl',
  11. ]);
  12. }
  13. }

Required Options

If an option must be set by the caller, pass that option tosetRequired().For example, to make the host option required, you can do:

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5.  
  6. public function configureOptions(OptionsResolver $resolver)
  7. {
  8. // ...
  9. $resolver->setRequired('host');
  10. }
  11. }

If you omit a required option, aMissingOptionsExceptionwill be thrown:

  1. $mailer = new Mailer();
  2.  
  3. // MissingOptionsException: The required option "host" is missing.

The setRequired()method accepts a single name or an array of option names if you have more thanone required option:

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5.  
  6. public function configureOptions(OptionsResolver $resolver)
  7. {
  8. // ...
  9. $resolver->setRequired(['host', 'username', 'password']);
  10. }
  11. }

Use isRequired() to findout if an option is required. You can usegetRequiredOptions() toretrieve the names of all required options:

  1. // ...
  2. class GoogleMailer extends Mailer
  3. {
  4. public function configureOptions(OptionsResolver $resolver)
  5. {
  6. parent::configureOptions($resolver);
  7.  
  8. if ($resolver->isRequired('host')) {
  9. // ...
  10. }
  11.  
  12. $requiredOptions = $resolver->getRequiredOptions();
  13. }
  14. }

If you want to check whether a required option is still missing from the defaultoptions, you can use isMissing().The difference between this and isRequired()is that this method will return false if a required option has alreadybeen set:

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5.  
  6. public function configureOptions(OptionsResolver $resolver)
  7. {
  8. // ...
  9. $resolver->setRequired('host');
  10. }
  11. }
  12.  
  13. // ...
  14. class GoogleMailer extends Mailer
  15. {
  16. public function configureOptions(OptionsResolver $resolver)
  17. {
  18. parent::configureOptions($resolver);
  19.  
  20. $resolver->isRequired('host');
  21. // => true
  22.  
  23. $resolver->isMissing('host');
  24. // => true
  25.  
  26. $resolver->setDefault('host', 'smtp.google.com');
  27.  
  28. $resolver->isRequired('host');
  29. // => true
  30.  
  31. $resolver->isMissing('host');
  32. // => false
  33. }
  34. }

The method getMissingOptions()lets you access the names of all missing options.

Type Validation

You can run additional checks on the options to make sure they were passedcorrectly. To validate the types of the options, callsetAllowedTypes():

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5.  
  6. public function configureOptions(OptionsResolver $resolver)
  7. {
  8. // ...
  9.  
  10. // specify one allowed type
  11. $resolver->setAllowedTypes('host', 'string');
  12.  
  13. // specify multiple allowed types
  14. $resolver->setAllowedTypes('port', ['null', 'int']);
  15.  
  16. // check all items in an array recursively for a type
  17. $resolver->setAllowedTypes('dates', 'DateTime[]');
  18. $resolver->setAllowedTypes('ports', 'int[]');
  19. }
  20. }

You can pass any type for which an is_<type>() function is defined in PHP.You may also pass fully qualified class or interface names (which is checkedusing instanceof). Additionally, you can validate all items in an arrayrecursively by suffixing the type with [].

If you pass an invalid option now, anInvalidOptionsExceptionis thrown:

  1. $mailer = new Mailer([
  2. 'host' => 25,
  3. ]);
  4.  
  5. // InvalidOptionsException: The option "host" with value "25" is
  6. // expected to be of type "string", but is of type "int"

In sub-classes, you can use addAllowedTypes()to add additional allowed types without erasing the ones already set.

Value Validation

Some options can only take one of a fixed list of predefined values. Forexample, suppose the Mailer class has a transport option which can beone of sendmail, mail and smtp. Use the methodsetAllowedValues()to verify that the passed option contains one of these values:

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5.  
  6. public function configureOptions(OptionsResolver $resolver)
  7. {
  8. // ...
  9. $resolver->setDefault('transport', 'sendmail');
  10. $resolver->setAllowedValues('transport', ['sendmail', 'mail', 'smtp']);
  11. }
  12. }

If you pass an invalid transport, anInvalidOptionsExceptionis thrown:

  1. $mailer = new Mailer([
  2. 'transport' => 'send-mail',
  3. ]);
  4.  
  5. // InvalidOptionsException: The option "transport" with value "send-mail"
  6. // is invalid. Accepted values are: "sendmail", "mail", "smtp"

For options with more complicated validation schemes, pass a closure whichreturns true for acceptable values and false for invalid values:

  1. // ...
  2. $resolver->setAllowedValues('transport', function ($value) {
  3. // return true or false
  4. });

In sub-classes, you can use addAllowedValues()to add additional allowed values without erasing the ones already set.

Option Normalization

Sometimes, option values need to be normalized before you can use them. Forinstance, assume that the host should always start with http://. To dothat, you can write normalizers. Normalizers are executed after validating anoption. You can configure a normalizer by callingsetNormalizer():

  1. use Symfony\Component\OptionsResolver\Options;
  2.  
  3. // ...
  4. class Mailer
  5. {
  6. // ...
  7.  
  8. public function configureOptions(OptionsResolver $resolver)
  9. {
  10. // ...
  11.  
  12. $resolver->setNormalizer('host', function (Options $options, $value) {
  13. if ('http://' !== substr($value, 0, 7)) {
  14. $value = 'http://'.$value;
  15. }
  16.  
  17. return $value;
  18. });
  19. }
  20. }

The normalizer receives the actual $value and returns the normalized form.You see that the closure also takes an $options parameter. This is usefulif you need to use other options during normalization:

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5. public function configureOptions(OptionsResolver $resolver)
  6. {
  7. // ...
  8. $resolver->setNormalizer('host', function (Options $options, $value) {
  9. if ('http://' !== substr($value, 0, 7) && 'https://' !== substr($value, 0, 8)) {
  10. if ('ssl' === $options['encryption']) {
  11. $value = 'https://'.$value;
  12. } else {
  13. $value = 'http://'.$value;
  14. }
  15. }
  16.  
  17. return $value;
  18. });
  19. }
  20. }

To normalize a new allowed value in sub-classes that are being normalizedin parent classes use addNormalizer().This way, the $value argument will receive the previously normalizedvalue, otherwise you can prepend the new normalizer by passing true asthird argument.

New in version 4.3: The addNormalizer() method was introduced in Symfony 4.3.

Default Values that Depend on another Option

Suppose you want to set the default value of the port option based on theencryption chosen by the user of the Mailer class. More precisely, you wantto set the port to 465 if SSL is used and to 25 otherwise.

You can implement this feature by passing a closure as the default value ofthe port option. The closure receives the options as argument. Based onthese options, you can return the desired default value:

  1. use Symfony\Component\OptionsResolver\Options;
  2.  
  3. // ...
  4. class Mailer
  5. {
  6. // ...
  7. public function configureOptions(OptionsResolver $resolver)
  8. {
  9. // ...
  10. $resolver->setDefault('encryption', null);
  11.  
  12. $resolver->setDefault('port', function (Options $options) {
  13. if ('ssl' === $options['encryption']) {
  14. return 465;
  15. }
  16.  
  17. return 25;
  18. });
  19. }
  20. }

Caution

The argument of the callable must be type hinted as Options. Otherwise,the callable itself is considered as the default value of the option.

Note

The closure is only executed if the port option isn't set by the useror overwritten in a sub-class.

A previously set default value can be accessed by adding a second argument tothe closure:

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5. public function configureOptions(OptionsResolver $resolver)
  6. {
  7. // ...
  8. $resolver->setDefaults([
  9. 'encryption' => null,
  10. 'host' => 'example.org',
  11. ]);
  12. }
  13. }
  14.  
  15. class GoogleMailer extends Mailer
  16. {
  17. public function configureOptions(OptionsResolver $resolver)
  18. {
  19. parent::configureOptions($resolver);
  20.  
  21. $resolver->setDefault('host', function (Options $options, $previousValue) {
  22. if ('ssl' === $options['encryption']) {
  23. return 'secure.example.org'
  24. }
  25.  
  26. // Take default value configured in the base class
  27. return $previousValue;
  28. });
  29. }
  30. }

As seen in the example, this feature is mostly useful if you want to reuse thedefault values set in parent classes in sub-classes.

Options without Default Values

In some cases, it is useful to define an option without setting a default value.This is useful if you need to know whether or not the user actually setan option or not. For example, if you set the default value for an option,it's not possible to know whether the user passed this value or if it comesfrom the default:

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5. public function configureOptions(OptionsResolver $resolver)
  6. {
  7. // ...
  8. $resolver->setDefault('port', 25);
  9. }
  10.  
  11. // ...
  12. public function sendMail($from, $to)
  13. {
  14. // Is this the default value or did the caller of the class really
  15. // set the port to 25?
  16. if (25 === $this->options['port']) {
  17. // ...
  18. }
  19. }
  20. }

You can use setDefined()to define an option without setting a default value. Then the option will onlybe included in the resolved options if it was actually passed toresolve():

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5.  
  6. public function configureOptions(OptionsResolver $resolver)
  7. {
  8. // ...
  9. $resolver->setDefined('port');
  10. }
  11.  
  12. // ...
  13. public function sendMail($from, $to)
  14. {
  15. if (array_key_exists('port', $this->options)) {
  16. echo 'Set!';
  17. } else {
  18. echo 'Not Set!';
  19. }
  20. }
  21. }
  22.  
  23. $mailer = new Mailer();
  24. $mailer->sendMail($from, $to);
  25. // => Not Set!
  26.  
  27. $mailer = new Mailer([
  28. 'port' => 25,
  29. ]);
  30. $mailer->sendMail($from, $to);
  31. // => Set!

You can also pass an array of option names if you want to define multipleoptions in one go:

  1. // ...
  2. class Mailer
  3. {
  4. // ...
  5. public function configureOptions(OptionsResolver $resolver)
  6. {
  7. // ...
  8. $resolver->setDefined(['port', 'encryption']);
  9. }
  10. }

The methods isDefined()and getDefinedOptions()let you find out which options are defined:

  1. // ...
  2. class GoogleMailer extends Mailer
  3. {
  4. // ...
  5.  
  6. public function configureOptions(OptionsResolver $resolver)
  7. {
  8. parent::configureOptions($resolver);
  9.  
  10. if ($resolver->isDefined('host')) {
  11. // One of the following was called:
  12.  
  13. // $resolver->setDefault('host', ...);
  14. // $resolver->setRequired('host');
  15. // $resolver->setDefined('host');
  16. }
  17.  
  18. $definedOptions = $resolver->getDefinedOptions();
  19. }
  20. }

Nested Options

Suppose you have an option named spool which has two sub-options typeand path. Instead of defining it as a simple array of values, you can pass aclosure as the default value of the spool option with aOptionsResolver argument. Based onthis instance, you can define the options under spool and its desireddefault value:

  1. class Mailer
  2. {
  3. // ...
  4.  
  5. public function configureOptions(OptionsResolver $resolver)
  6. {
  7. $resolver->setDefault('spool', function (OptionsResolver $spoolResolver) {
  8. $spoolResolver->setDefaults([
  9. 'type' => 'file',
  10. 'path' => '/path/to/spool',
  11. ]);
  12. $spoolResolver->setAllowedValues('type', ['file', 'memory']);
  13. $spoolResolver->setAllowedTypes('path', 'string');
  14. });
  15. }
  16.  
  17. public function sendMail($from, $to)
  18. {
  19. if ('memory' === $this->options['spool']['type']) {
  20. // ...
  21. }
  22. }
  23. }
  24.  
  25. $mailer = new Mailer([
  26. 'spool' => [
  27. 'type' => 'memory',
  28. ],
  29. ]);

Nested options also support required options, validation (type, value) andnormalization of their values. If the default value of a nested option dependson another option defined in the parent level, add a second Options argumentto the closure to access to them:

  1. class Mailer
  2. {
  3. // ...
  4.  
  5. public function configureOptions(OptionsResolver $resolver)
  6. {
  7. $resolver->setDefault('sandbox', false);
  8. $resolver->setDefault('spool', function (OptionsResolver $spoolResolver, Options $parent) {
  9. $spoolResolver->setDefaults([
  10. 'type' => $parent['sandbox'] ? 'memory' : 'file',
  11. // ...
  12. ]);
  13. });
  14. }
  15. }

Caution

The arguments of the closure must be type hinted as OptionsResolver andOptions respectively. Otherwise, the closure itself is considered as thedefault value of the option.

In same way, parent options can access to the nested options as normal arrays:

  1. class Mailer
  2. {
  3. // ...
  4.  
  5. public function configureOptions(OptionsResolver $resolver)
  6. {
  7. $resolver->setDefault('spool', function (OptionsResolver $spoolResolver) {
  8. $spoolResolver->setDefaults([
  9. 'type' => 'file',
  10. // ...
  11. ]);
  12. });
  13. $resolver->setDefault('profiling', function (Options $options) {
  14. return 'file' === $options['spool']['type'];
  15. });
  16. }
  17. }

Note

The fact that an option is defined as nested means that you must passan array of values to resolve it at runtime.

Deprecating the Option

Once an option is outdated or you decided not to maintain it anymore, you candeprecate it using the setDeprecated()method:

  1. $resolver
  2. ->setDefined(['hostname', 'host'])
  3. // this outputs the following generic deprecation message:
  4. // The option "hostname" is deprecated.
  5. ->setDeprecated('hostname')
  6.  
  7. // you can also pass a custom deprecation message
  8. ->setDeprecated('hostname', 'The option "hostname" is deprecated, use "host" instead.')
  9. ;

Note

The deprecation message will be triggered only if the option is being usedsomewhere, either its value is provided by the user or the option is evaluatedwithin closures of lazy options and normalizers.

Note

When using an option deprecated by you in your own library, you can passfalse as the second argument of theoffsetGet()() methodto not trigger the deprecation warning.

Instead of passing the message, you may also pass a closure which returnsa string (the deprecation message) or an empty string to ignore the deprecation.This closure is useful to only deprecate some of the allowed types or values ofthe option:

  1. $resolver
  2. ->setDefault('encryption', null)
  3. ->setDefault('port', null)
  4. ->setAllowedTypes('port', ['null', 'int'])
  5. ->setDeprecated('port', function (Options $options, $value) {
  6. if (null === $value) {
  7. return 'Passing "null" to option "port" is deprecated, pass an integer instead.';
  8. }
  9.  
  10. // deprecation may also depend on another option
  11. if ('ssl' === $options['encryption'] && 456 !== $value) {
  12. return 'Passing a different port than "456" when the "encryption" option is set to "ssl" is deprecated.';
  13. }
  14.  
  15. return '';
  16. })
  17. ;

Note

Deprecation based on the value is triggered only when the option is providedby the user.

This closure receives as argument the value of the option after validating itand before normalizing it when the option is being resolved.

Performance Tweaks

With the current implementation, the configureOptions() method will becalled for every single instance of the Mailer class. Depending on theamount of option configuration and the number of created instances, this may addnoticeable overhead to your application. If that overhead becomes a problem, youcan change your code to do the configuration only once per class:

  1. // ...
  2. class Mailer
  3. {
  4. private static $resolversByClass = [];
  5.  
  6. protected $options;
  7.  
  8. public function __construct(array $options = [])
  9. {
  10. // What type of Mailer is this, a Mailer, a GoogleMailer, ... ?
  11. $class = get_class($this);
  12.  
  13. // Was configureOptions() executed before for this class?
  14. if (!isset(self::$resolversByClass[$class])) {
  15. self::$resolversByClass[$class] = new OptionsResolver();
  16. $this->configureOptions(self::$resolversByClass[$class]);
  17. }
  18.  
  19. $this->options = self::$resolversByClass[$class]->resolve($options);
  20. }
  21.  
  22. public function configureOptions(OptionsResolver $resolver)
  23. {
  24. // ...
  25. }
  26. }

Now the OptionsResolver instancewill be created once per class and reused from that on. Be aware that this maylead to memory leaks in long-running applications, if the default options containreferences to objects or object graphs. If that's the case for you, implement amethod clearOptionsConfig() and call it periodically:

  1. // ...
  2. class Mailer
  3. {
  4. private static $resolversByClass = [];
  5.  
  6. public static function clearOptionsConfig()
  7. {
  8. self::$resolversByClass = [];
  9. }
  10.  
  11. // ...
  12. }

That's it! You now have all the tools and knowledge needed to processoptions in your code.