symfony/options-resolver
OptionsResolver is array_replace on steroids: build option systems with required options, defaults, type and value validation, and normalization, with clear exceptions when configuration is invalid.
What this actually is. symfony/options-resolver is a framework-agnostic configuration-schema component, not a Laravel package. It gives you a declarative contract for an options array: required/optional keys, defaults, allowed types and values, normalizers, nested option sets, deprecation flags, and a resolve() call that throws on anything that doesn't match the contract. There is no service provider, no facade, no Blade/queue/HTTP integration — it is a pure PHP library we would consume inside our own code.
Architecture fit. Good fit as a boundary/adapter layer between untrusted-but-trusted-by-us configuration input and our services. The places it maps cleanly onto a Laravel codebase:
config/*.php payloads for first-party packages, validated once at provider boot rather than scattered config('x.y', $default) calls.It does not belong in the request-validation path. Laravel's Validator/FormRequest is the right tool for user input (localized messages, HTTP 422 semantics, Rule objects). OptionsResolver throws exceptions and is designed for developer-authored configuration, so using it on user input would convert validation errors into 500s.
Integration feasibility. Very high. composer require symfony/options-resolver is a single, extension-free dependency; in practice it is often already present transitively (Symfony Console, HttpClient, Mime, Translation, and many third-party Laravel packages pull it in). Before any decision, run composer show symfony/options-resolver and composer why symfony/options-resolver — if it's already in the lock file, the marginal cost of adopting it is documentation and code review, not dependency risk.
Technical risk. Low-to-moderate, and concentrated in version management rather than in the library itself:
setDefault() in favour of setOptions(); 8.0 removed the old path and requires PHP ≥ 8.4. If we adopt, we should adopt setOptions() from day one so the eventual v7→v8 move is a composer bump, not a refactor.resolve() returns array; the type information lives in docblocks and @var annotations. PHPStan can't prove a resolved array's shape, so IDE/static coverage of option bags is weaker than a typed DTO. This is the main "why not just a DTO?" counterargument.UndefinedOptionsException / InvalidOptionsException / MissingOptionsException are developer-facing messages, not localized or user-safe; they need mapping into our logging/exception handler policy.symfony/symfony; stars, and especially the reported "Dependents: 0", understate real adoption by orders of magnitude. Judge it by the parent repo's maintenance record, not this one.Key questions for the team.
composer.lock transitively? If yes, which version, and who owns its constraint?Data/Spatie\LaravelData-style object is a better fit than an array resolver?Stack fit. Works with any Laravel version on a supported PHP; it is framework-agnostic and has zero coupling to Laravel's container, config cache, or event system. The integration work is therefore convention work, not wiring work. Recommended shape:
ConfigurationServiceProvider (one binding per contract: PaymentGatewayConfig, QueueWorkerConfig, etc.) and inject array results, or inject a small typed wrapper.config/*.php so php artisan config:cache stays a plain array cache; build resolvers in providers from config() values.Migration path (inventory → pilot → standardize → retire).
array_replace, array_merge defaults, isset($opts[...]) guards, and config('x', $default) chains. Tag each site by blast radius (does a misconfiguration take down payments/queues/API clients, or just a report?). Expect the highest-value targets to be queue workers, mail/SMS transports, and third-party SDK wrappers.App\Support\Options\Resolver) that fixes our conventions: exception-to-log mapping, redaction of secret options before logging, a describe() method that dumps the schema for docs, and a rule that nested options always use setOptions(), never setDefault().setOptions() usage and no closure defaults touching I/O.setDeprecated() on keys being renamed, with a resolution date and a metric on how often the deprecated path fires; remove once the metric is flat for 30 days.Compatibility and versioning. Pin to the resolver major that matches our PHP floor, use caret constraints, and treat the Symfony major as a tracked dependency in the platform upgrade plan rather than something that arrives via composer update. If any production service must stay on PHP 8.2/8.3, we land on the 7.x line and should note that the 7.3 deprecations are our deadline for setOptions() adoption. Prefer an LTS line (6.4 or 7.4) where we need a long support runway.
Sequencing. Pilot before standard before package-wide adoption. Do not attempt a "configuration refactor" programme; adopt it opportunistically whenever a service is already being touched, plus the small set of high-risk call sites identified in step 1.
Maintenance. Upstream is owned by the Symfony core team, MIT-licensed, with a steady patch cadence (multiple releases across 5.4 → 8.1 lines visible in the last cycles) and a clear deprecation policy. Our exposure is limited to consuming it; we will never patch it. The maintenance burden we take on is internal: one base class, a documented convention, and a version-pin review on each Symfony major.
Support. No commercial SLA and no vendor to escalate to. Support is community + docs, and the docs live on symfony.com and are Symfony-framed (YAML service configuration, FrameworkBundle context) rather than Laravel-framed. Plan for an internal one-pager ("how we use OptionsResolver in this repo") and a Slack channel owner; budget roughly a day of one engineer's time to write it. Risk of abandonment is negligible for a component this widely depended upon; risk of documentation drift from our Laravel reality is the real support issue.
Scaling. Resolution cost is CPU-only, O(number of options) with a small constant, and happens once per resolver instance. Under steady traffic this is noise compared to a single database query — provided we honour the singleton binding and don't rebuild resolvers per request or per job. The scaling hazard is behavioural, not computational: closure defaults that hit env(), config() after cache warm, or (worst case) the database, evaluated at resolve time, can turn a cached-boot path into a per-request I/O path. Ban I/O in defaults by review rule.
Failure modes and mitigations.
MissingOptionsException → every request 500s. Mitigation: resolve in the provider, but wrap contract construction in a test-time assertion (php artisan about-style health check) rather than first-request; add a config:validate artisan command that resolves every registered schema and runs in CI and at deploy.setRequired() for anything that has no safe default, and a test asserting the required set.InvalidOptionsException reaching the user becomes an opaque 500 (or, worse, leaks config values). Mitigation: map these to a domain exception in the base class, log the option name but redact the value for keys tagged secret.setDeprecated() fires on every resolve; at scale that's log volume. Mitigation: sample or route to a metric, with a removal date attached.setDefault() stops working. Mitigation: lint rule or review checklist requiring setOptions() from day one.$request->all() into a resolver. Mitigation: explicit convention in the base class docblock — configuration only; user input goes through Validator.Ramp-up. A Laravel developer with the docs open is productive in about an hour; the API is small (setRequired, setDefaults, setAllowedTypes, setAllowedValues, setNormalizer, setOptions, resolve). Realistic ramp: half a day to the first reviewed schema, one to two days to internal fluency including our base class and conventions. The cost is almost entirely in knowing which options are required and what types are valid — the domain knowledge, not the library — and that knowledge is exactly what the schema captures permanently, which is the durable payback.
How can I help you explore Laravel packages today?