Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Config][DependencyInjection] Add NodeDefinition::inlineEnvVars() to inline env vars while the container is compiled - #66033

Open
nicolas-grekas wants to merge 3 commits into
symfony:8.2from
nicolas-grekas:config-inline-env-vars
Open

[Config][DependencyInjection] Add NodeDefinition::inlineEnvVars() to inline env vars while the container is compiled#66033
nicolas-grekas wants to merge 3 commits into
symfony:8.2from
nicolas-grekas:config-inline-env-vars

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Sep 13, 2026

Copy link
Copy Markdown
Member
Q A
Branch? 8.2
Bug fix? no
New feature? yes
Deprecations? no
Issues Ref #40794
License MIT

Some configuration options need their value while the container is built: to decide which services to register, to turn a log level into a constant, or to fill an array node. An env var cannot be used there, because what reaches the extension is a placeholder string. That is the half of #40794 that is still open. The other half, the schema rejecting placeholders, ValidateEnvPlaceholdersPass already handles when a cast prefix is used.

A node can say so now:

->booleanNode('enabled')->inlineEnvVars()->end()
->scalarNode('level')->inlineEnvVars()->end()
->arrayNode('enabled_locales')->inlineEnvVars()->scalarPrototype()->end()->end()

The env vars such a node references are replaced by their value before the extension is loaded, so $config['enabled'] is a real false and $config['enabled_locales'] a real array. The value is inlined in the compiled container, so no getEnv() call is left for it and changing the variable needs a new build. Nodes without the flag are untouched. When the value is not known at that point, compiling fails and names the option:

The value of the configuration option "env_extension.level" must be known when the container is compiled: Environment variable not found: "UNDEFINED_STATIC".

The node declares it, not the host. #40794 looked for an app-wide or host-wide switch, SYMFONY_DYNAMIC_ENV_VARS, listing the variables that stay dynamic. But almost every %env()% reference lands on a configuration node, and the node is the only thing that knows whether the value is needed at build time. Nothing ships in .env, and no recipe is involved. A switch stays possible on top of this, and is orthogonal to it.

The second commit flags the enabled node. canBeEnabled() and canBeDisabled() generate a boolean node that every extension reads to decide what to register, which is the case the feature exists for. It is a behavior change, [BC BREAK] in UPGRADE-8.2.md: enabled: '%env(bool:PROFILER)%' used to hand the extension a placeholder, which is truthy, so the section was enabled whatever PROFILER held. Sibling options are untouched, so dsn: '%env(PROFILER_DSN)%' in the same section still resolves at runtime. FrameworkBundle and SecurityBundle, which carry the most enableable sections, pass unchanged.

The part worth weighing is the new failure. An app naming a variable that has no value at compile time used to compile and silently enable the section, and now fails. Defining it in .env, or writing %env(bool:default:some_param:PROFILER)%, is the fix.

The third commit answers "can I still change this one at runtime?" That is the question debug:container --env-vars exists for, and the one #40794 keeps coming back to, but the listing showed an inlined variable exactly like any other. A new column reports it, and it covers an extension that resolves a variable itself as well as a node that declares it, which is what valtzu asked for in #1632998562.

  Name           Default value   Real value   Used   Inlined
  FORM_ENABLED   "true"          n/a          yes    yes
  REAL           n/a             "value"      yes    no

A note under the table says that changing an inlined variable needs a rebuild. debug:container --env-var=NAME gains the same line.

Notes for review

BaseNode::normalize() asks a resolver, which MergeExtensionConfigurationPass registers for the duration of Extension::load(). It goes through ContainerBuilder::resolveEnvPlaceholders($value, true), so core processors and variables present in the real environment resolve, while env var loaders and custom processors, which are not registered that early, do not. A variable that comes from a vault therefore stays dynamic.

MergeExtensionConfigurationContainerBuilder::resolveEnvPlaceholders() refuses compile-time resolution of a cast env var, which is the guard against an extension doing this behind the framework's back. It stays. The new path calls parent:: and skips it, because here the node declared the requirement rather than the extension taking it.

ValidateEnvPlaceholdersPass runs later and re-processes the raw configuration, and by then the placeholder has been dropped from the parameter bag as unused, so it can no longer be resolved there. It does not need to be: the merge pass either settled the value or failed, so doValidateType() skips dynamic-value validation for these nodes. Registering a second resolver in that pass was the first attempt, and it silently resolved nothing.

That same reclassification is what the debug commands trip on. Once the placeholder counts as unused, the pass clears it, and debug:config has nothing left to map it back with, so it printed the raw internal id. The pass now names those placeholders back to their %env(NAME)% spelling before clearing them: debug:config prints %env(bool:PROFILER)% as it does for any other option, and --resolve-env prints the value that was compiled in.

The Inlined column cannot be read off the parameter bag, which counts a variable as read when the processed configuration holds it, even when no definition does. That misses an extension resolving a variable itself, the Monolog pattern. The pass already resolves the placeholders of a copy of the container in order to serialize it, so doing that first answers the question exactly, for both the node path and the extension path. Placeholders that live in parameters rather than in definitions are counted too, otherwise kernel.trusted_hosts and the other SYMFONY_TRUSTED_* variables read as inlined when they are not.

The scalar shorthand of an enableable section, profiler: '%env(bool:PROFILER)%', is not covered. The placeholder lands on the array node, which rejects it with A dynamic value is not compatible with an "ArrayNode" node type, as it already does. Flagging the section instead of the child would cover it, but resolveEnvPlaceholders() walks arrays, so the flag would then inline every variable in the subtree.

Config, DependencyInjection, FrameworkBundle and SecurityBundle all pass, and each of the new tests fails on the base with the error it is there to catch. Nothing outside those four packages was run locally.

Left for later

No adoption beyond the enabled node. Each option is its own call, and framework.enabled_locales and the monolog handler level are the obvious next ones.

No inference either. A node that can never hold a placeholder, any ArrayNode or an EnumNode, could carry the flag implicitly, since the only outcome today is an exception, and that is what #50323 is. It deserves its own pull request.

The open design point

The flag is read per node, so one variable can be inlined for one option and stay dynamic for another. #40794 argued that static versus dynamic is a property of the variable rather than of the reference. The counter-argument is that a node states a requirement about its own value, not a preference, and another option may legitimately read the same variable at runtime. Pinning the variable everywhere as soon as one node carries the flag is a small change on top of this.

The node-level declaration was first proposed in #40794 by @alexander-schranz, with the polarity @webmozart argued for there: flag the exception, not the rule, so that nothing breaks by not adopting it.

…inline env vars while the container is compiled

Some configuration options need their value while the container is built: to
decide which services to register, to turn a log level into a constant, or to
fill an array node. Env vars cannot be used there today, because what reaches
the extension is a placeholder, not the value.

NodeDefinition::inlineEnvVars() lets a node declare that need. The env vars it
references are replaced by their value before the extension is loaded, so the
extension gets the real value with its real type.
…ions

An extension decides what to register by reading $config['enabled'], so the
value has to be known while the container is compiled. Until now it received a
placeholder there, which is always truthy, so a section configured with an env
var was enabled whatever the value of that var.

The node generated by canBeEnabled() and canBeDisabled() now inlines the env
vars it references. Compiling fails when the value is not known at that point,
naming the option that needs it.
…--env-vars

A variable that is read while the container is compiled, by a configuration
node or by an extension resolving it itself, is read once: changing it has no
effect until the container is rebuilt. The listing gave no way to tell those
apart from the ones that are read at runtime, which is the question the command
exists to answer.

The pass already resolves the placeholders of a copy of the container to
serialize it. Doing that before the listing is built reports which variables
the container still reads when it runs, and the rest are inlined. The bag
cannot answer this on its own: it counts a variable as read when the processed
configuration holds it, even when no definition does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants