symfony/config
The Symfony Config component helps you find, load, combine, autofill, and validate configuration values from any source—YAML, XML, INI files, or even a database—giving your PHP applications flexible, reliable configuration handling.
For a Laravel developer, symfony/config is not a replacement for config/*.php, config() or php artisan config:cache. It is a schema engine: you describe the shape of a configuration array once, and the component merges multiple sources, applies defaults, normalizes legacy formats, validates types/ranges/enums, and throws a precise exception with the failing path. Day-to-day, that makes it a package-author tool. If you maintain a package with a published config file, or an app where config comes from files plus database/tenant overrides, this is the cleanest way in PHP to stop hand-rolling isset()/is_array() validation. If you only have a handful of scalar settings, Laravel's Validator or plain casts are cheaper.
Install and check what you already have:
composer require symfony/config
composer show symfony/config # often already present transitively
The first classes to read are Symfony\Component\Config\Definition\Builder\TreeBuilder (schema definition), Symfony\Component\Config\Definition\Processor (merge + validate), InvalidConfigurationException (error handling), and ConfigCache/Resource\* only if you need your own cache invalidation beyond Laravel's.
The smallest useful setup is a ConfigurationInterface class plus a Processor call:
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
final class AuditConfiguration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$tree = new TreeBuilder('audit');
$children = $tree->getRootNode()->children();
$children->booleanNode('enabled')->defaultTrue();
$children->enumNode('driver')->values(['log', 'db'])->defaultValue('log');
$children->integerNode('retention_days')->min(1)->max(365)->defaultValue(30);
$events = $children->arrayNode('events');
$events->scalarPrototype()->end();
$events->defaultValue(['created', 'updated']);
return $tree;
}
}
$config = (new Processor())->processConfiguration(
new AuditConfiguration(),
[require config_path('audit.php')]
);
Your first real use case: in a package service provider, register the validated config as a singleton and let the tree supply defaults, instead of duplicating defaults in the published file and in code:
public function register(): void
{
$this->app->singleton(AuditConfig::class, function ($app) {
return new AuditConfig(
(new Processor())->processConfiguration(
new AuditConfiguration(),
[$app['config']->get('audit', [])]
)
);
});
}
The component also ships a YamlReferenceDumper (what Symfony's config:dump-reference uses), which is a cheap way to generate docs for your package config from the same tree.
Put defaults in the tree, not in the published config file. A published config/audit.php should contain only user-editable values. Every default, range and enum belongs in the tree so there is one source of truth. This also sidesteps Laravel's mergeConfigFrom() being shallow (it uses array_merge, not array_replace_recursive) — nested package defaults get wiped by a user's partial config. Let the tree fill the gaps after merging.
Use the Processor for precedence, not array_merge. Pass configs in ascending priority; later arrays win. Symfony deep-merges array nodes, which is usually what you want for options and usually not what you want for lists.
$config = (new Processor())->processConfiguration(new AuditConfiguration(), [
$packageDefaults, // framework fallbacks
$publishedFile, // config/audit.php
$tenantOverrides, // database / per-tenant settings
]);
For list-like nodes, disable deep merging so an override replaces rather than appends: $node->performNoDeepMerging(). For a whole array node that must be replaced wholesale, ->cannotBeOverwritten() prevents later sources from touching it at all.
Model repeated blocks with prototypes. Channels, queues, connections and disks are where hand-rolled validation falls apart:
$channels = $children->arrayNode('channels')->useAttributeAsKey('name');
$channels->normalizeKeys(false);
$channels->arrayPrototype()
->children()
->scalarNode('driver')->defaultValue('single')->end()
->scalarNode('path')->cannotBeEmpty()->end()
->integerNode('level')->defaultValue(100)->end()
->end();
$channels->requiresAtLeastOneElement();
$channels->defaultValue([]);
Normalize legacy and env-shaped input at the edge of the tree. beforeNormalization() runs per source before merging, which is the right place to accept back-compatible keys or string booleans coming from .env:
$children->booleanNode('enabled')
->beforeNormalization()
->ifString()
->then(static fn (string $v) => filter_var($v, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $v)
->end()
->defaultTrue();
Prefer doing the env() resolution in the Laravel config file ('enabled' => (bool) env('AUDIT_ENABLED', true)) and keep the schema environment-agnostic.
Validate at deploy time, not on every request. Building and processing a tree is not free, and in production php artisan config:cache has already frozen config. Add a small Artisan command (audit:validate-config) and run it in CI/deploy; or cache the processed array keyed by the file hash. If you want resource-aware invalidation outside Laravel's cache, use ConfigCache:
use Symfony\Component\Config\ConfigCache;
use Symfony\Component\Config\Resource\FileResource;
$cache = new ConfigCache(storage_path('framework/audit-config.php'), app()->environment('local'));
if (! $cache->isFresh()) {
$config = (new Processor())->processConfiguration(
new AuditConfiguration(),
[require config_path('audit.php')]
);
$cache->write('<?php return '.var_export($config, true).';', [new FileResource(config_path('audit.php'))]);
}
$config = require storage_path('framework/audit-config.php');
Non-PHP sources are just arrays. The Config component does not ship a YAML/XML/JSON parser of its own; it has generic loaders (FileLoader, DelegatingLoader, GlobFileLoader, DirectoryLoader, LoaderResolver). In Laravel, parse the file yourself with Symfony\Component\Yaml\Yaml::parseFile() or json_decode(), then feed the array to the Processor. This is the pattern for DB-backed settings: query, decode, pass as the highest-priority array.
Use the right node types. enumNode()->values(Driver::class) (or enumFqcn() for backed enums) gives you enum validation and defaultValue(Driver::Log). variableNode() accepts arbitrary mixed data and is the correct escape hatch for "free-form options" — do not use scalarNode for arrays or objects.
Generate docs/static analysis from the tree. If you publish a package, dumping a reference from the tree keeps README/config docs in sync. Be cautious about building new tooling on Symfony's fluent PHP config-builder generators: they are deprecated as of Symfony 8.0.
mergeConfigFrom() is shallow. Laravel's helper uses array_merge, so a user's nested array replaces your nested defaults. Rely on tree defaults, or use array_replace_recursive explicitly, but do not expect mergeConfigFrom to deep-merge.PrototypedArrayNode::normalizeKeys(true) converts my-channel to my_channel, which silently breaks lookups by the key users wrote. Call ->normalizeKeys(false) whenever keys are user-chosen (channel names, queue names, tenant IDs).logging.channels is interpreted as a path. If you need dotted keys, change the separator with setPathSeparator('/') or keep dotted keys as dynamic array keys under a single node.ignoreExtraKeys(true) — note that the second argument controls whether the extra keys are removed from the result (false keeps them).isRequired() with defaultValue(). A required node with a default is contradictory; Symfony 8 deprecates/removes this. Required means "must be present".BooleanNode wants real booleans. .env values are strings, and "false" is not false to this component. Cast in the config file or normalize with beforeNormalization(); otherwise you get InvalidTypeException in production after an env change.%env(FOO)% is not resolved here. Env placeholders are a DependencyInjection feature (EnvPlaceholderParameterBag), not a Config feature. Numeric nodes tolerate placeholder strings in recent 6.4/7.x/8.x releases, but standalone use will leave them as strings. Resolve with Laravel's env()/config() before processing.env() returns null once config is cached. Do not read env() inside a service provider or schema — Laravel only evaluates env() from config files when config:cache runs. Always validate values that come through config().canBeEnabled()/canBeDisabled() inject an enabled key and have had merge bugs fixed in 7.4.4/8.0.4. If you support older patch versions, pin ^7.4.6/^8.0.6 or avoid combining them with treatNullLike/treatFalseLike.->addDefaultsIfNotSet() when you need the merged result to always contain the fully defaulted structure. ->requiresAtLeastOneElement() and ->defaultValue([]) are separate concerns.->end() drift is the classic authoring bug. One missing end() attaches nodes to the wrong parent and produces confusing "Unrecognized option" errors. Assign the NodeBuilder to a variable and add siblings without long chains, or dump the tree with YamlReferenceDumper while building it.InvalidConfigurationException (it implements the component's Exception interface) and surface $e->getMessage(), which includes the full path like audit.channels.daily.path. In a package, wrap it in your own exception mentioning the config file name, otherwise users see a wall of nested array output.symfony/config 7.4.x. Pin accordingly and check composer why symfony/config before assuming the major version.__sleep/__wakeup replaced by __serialize/__unserialize). Cache the plain processed array, as in the ConfigCache example.GlobResource, DirectoryResource, ReflectionClassResource and ClassExistenceResource exist for ConfigCache freshness checks; if you validate on deploy only, you do not need them and they add noise.Config::set('audit.driver', 'x') after boot will not be checked. If tenants or admin panels mutate config at runtime, route those values through the same Processor call (or a dedicated validator) rather than trusting the boot-time result.How can I help you explore Laravel packages today?