[Config][DependencyInjection] Add NodeDefinition::inlineEnvVars() to inline env vars while the container is compiled - #66033
Open
nicolas-grekas wants to merge 3 commits into
Open
Conversation
…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.
nicolas-grekas
force-pushed
the
config-inline-env-vars
branch
from
September 13, 2026 08:12
e22e976 to
c0a5101
Compare
…--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.
nicolas-grekas
force-pushed
the
config-inline-env-vars
branch
from
September 13, 2026 08:41
296971b to
512a8cf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,
ValidateEnvPlaceholdersPassalready handles when a cast prefix is used.A node can say so now:
The env vars such a node references are replaced by their value before the extension is loaded, so
$config['enabled']is a realfalseand$config['enabled_locales']a real array. The value is inlined in the compiled container, so nogetEnv()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 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
enablednode.canBeEnabled()andcanBeDisabled()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]inUPGRADE-8.2.md:enabled: '%env(bool:PROFILER)%'used to hand the extension a placeholder, which is truthy, so the section was enabled whateverPROFILERheld. Sibling options are untouched, sodsn: '%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-varsexists 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.A note under the table says that changing an inlined variable needs a rebuild.
debug:container --env-var=NAMEgains the same line.Notes for review
BaseNode::normalize()asks a resolver, whichMergeExtensionConfigurationPassregisters for the duration ofExtension::load(). It goes throughContainerBuilder::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 callsparent::and skips it, because here the node declared the requirement rather than the extension taking it.ValidateEnvPlaceholdersPassruns 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, sodoValidateType()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:confighas 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:configprints%env(bool:PROFILER)%as it does for any other option, and--resolve-envprints the value that was compiled in.The
Inlinedcolumn 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, otherwisekernel.trusted_hostsand the otherSYMFONY_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 withA dynamic value is not compatible with an "ArrayNode" node type, as it already does. Flagging the section instead of the child would cover it, butresolveEnvPlaceholders()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
enablednode. Each option is its own call, andframework.enabled_localesand the monolog handler level are the obvious next ones.No inference either. A node that can never hold a placeholder, any
ArrayNodeor anEnumNode, 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.