Skip to content

feat(symfony): parse and merge config/packages with env resolution#1142

Merged
Soner (shyim) merged 1 commit into
nextfrom
feat/symfony-config-packages
Jun 30, 2026
Merged

feat(symfony): parse and merge config/packages with env resolution#1142
Soner (shyim) merged 1 commit into
nextfrom
feat/symfony-config-packages

Conversation

@shyim

Copy link
Copy Markdown
Member

Summary

Adds a ProjectConfig abstraction in internal/symfony that reads a Symfony project's config/packages tree across all environments with correct Symfony merge semantics, and resolves %env(...)% references. Intended as a foundation for further project optimizations.

API

pc, _ := symfony.NewProjectConfig(projectRoot)

pc.Environments()                                  // ["dev", "prod", "test"]
pc.Config("prod")                                  // fully merged map[string]any, keyed by package
pc.PackageConfig("prod", "framework")              // one package's merged config
pc.GetConfigValue("prod", "framework.cache.app")   // dotted-path read (raw %env() kept)
pc.SetConfigValue("prod", "framework.cache.app", "...") // comment-preserving in-place write
pc.Files()                                          // raw parsed files in load order

pc.Env()                                            // merged .env content (.env.dist < .env < .env.local)
pc.ResolvedConfig("prod")                          // merged config with %env() resolved
pc.GetResolvedConfigValue("dev", "env_demo.retry") // dotted-path read, resolved
pc.ResolveEnvExpression("%env(REDIS_URL)%")        // resolve a single expression

Merge semantics (matches the Symfony kernel)

  • Load precedence low→high: config/packages/*.yamlwhen@<env> blocks in base files → config/packages/<env>/*.yamlwhen@<env> blocks in env files.
  • Maps merge recursively; sequences and scalars replace (lists are not concatenated).
  • when@<env> blocks only contribute to their own environment.

Env-var resolution

  • Opt-in: raw Config()/GetConfigValue() keep literal %env(...)% strings (safe for write round-trips); the Resolved* variants resolve.
  • Reads the project's .env chain via internal/envfile (Symfony precedence). The resolver loads pc.Env() once and reuses that map.
  • Supported processors: bare VAR, casts bool/int/float/string/trim/not/json/csv/base64, default:param:VAR, and env(VAR) parameter defaults. file:/require:/resolve: are best-effort pass-throughs (no PHP/container context).

Writes

SetConfigValue edits the source file via yaml.Node, preserving comments, key order and unrelated formatting. Target file mirrors where Symfony reads the value: deepest existing file defining the path, else a per-package file in config/packages/<env>/ when that dir exists, else config/packages/<pkg>.yaml. Reloads after write.

Tests

New config_packages_test.go, config_packages_merge_test.go, config_packages_env_test.go with a fixture project under testdata/config-packages/. Covers merge precedence, list replacement, when@ scoping, comment-preserving and file-creating writes, env resolution + processors, and param-default fallback. go test ./..., go vet, gofmt clean.

Notes / limitations

  • Reads config/packages only — not config/services.yaml, routes, or container parameters.
  • default:<param>:VAR only resolves <param> when declared as an env(...) default; arbitrary container parameters are not resolved.

@shyim Soner (shyim) force-pushed the feat/symfony-config-packages branch from 87224d1 to 5832496 Compare June 30, 2026 07:55
Add a ProjectConfig abstraction in internal/symfony that reads a Symfony
project's config/packages tree across all environments with correct
Symfony merge semantics, and resolves %env(...)% references.

- NewProjectConfig discovers and parses every YAML file under
  config/packages, including config/packages/<env>/ directories.
- Config(env) returns the fully merged config keyed by package, following
  Symfony precedence (base files < when@<env> blocks < env files) with
  recursive map merge and list/scalar replacement.
- Environments() lists environments from both <env>/ dirs and when@<env>
  blocks; GetConfigValue/PackageConfig provide dotted-path reads.
- SetConfigValue performs comment-preserving in-place writes via yaml.Node,
  targeting the file Symfony itself would read the value from.
- ResolvedConfig/GetResolvedConfigValue/ResolveEnvExpression resolve
  %env(...)% against the project's .env files, supporting the common
  processor subset (bool/int/float/string/trim/json/csv/base64, default,
  and env(VAR) parameter defaults).
- Env() exposes the merged .env content; the resolver reuses that same map.
- envfile.ReadAll returns the full merged env map.
@shyim Soner (shyim) force-pushed the feat/symfony-config-packages branch from 5832496 to 2df02e3 Compare June 30, 2026 08:04
@shyim Soner (shyim) marked this pull request as ready for review June 30, 2026 08:10
@shyim Soner (shyim) merged commit b5f3d9e into next Jun 30, 2026
3 checks passed
@shyim Soner (shyim) deleted the feat/symfony-config-packages branch June 30, 2026 08:10

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2df02e3b75

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

return nil, fmt.Errorf("decoding %s: %w", file.Path, err)
}

for key, value := range root {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve YAML order when merging when blocks

When a YAML file contains both a normal package key and a matching when@<env> override for the same path, iterating the decoded map[string]any makes the merge order arbitrary. If the map yields when@dev before framework, the base value is merged afterward and can overwrite the environment-specific override, so Config("dev") becomes nondeterministic for common Symfony files.

Useful? React with 👍 / 👎.

Comment on lines +115 to +118
if when := mappingChild(root, whenPrefix+environment); when != nil {
if d := mappingDepth(when, segments); d > depth {
depth = d
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep writes inside matching when blocks

Because definedDepth counts paths found under when@<env>, SetConfigValue("test", "framework.test", ...) selects a file where the existing value lives only in when@test, but set writes the path at the document root. In that case the original when@test value still overrides the write for test, and the new root value also leaks into other environments.

Useful? React with 👍 / 👎.

Comment on lines +96 to +101
files := resolveEnvFiles(projectRoot)
if len(files) == 0 {
return map[string]string{}, nil
}

return godotenv.Read(files...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load environment-specific env files

ResolvedConfig(environment) reaches this helper via Env(), but ReadAll has no environment and delegates to resolveEnvFiles, which only returns .env.dist, .env, and .env.local. For prod/test projects that put values in .env.prod or .env.prod.local, %env()% references resolve as missing or stale when asking for that environment.

Useful? React with 👍 / 👎.

return nil, ok, err
}

resolver, err := pc.newEnvResolver(nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build the single-value resolver with config defaults

This path-specific API constructs the resolver with nil config, so collectEnvParamDefaults never sees defaults declared in the merged config. A missing variable that ResolvedConfig would satisfy from env(VAR) defaults comes back as empty or as the original %env(...)% expression through GetResolvedConfigValue, making the two resolving APIs disagree for the same path.

Useful? React with 👍 / 👎.

Comment on lines +353 to +354
params, ok := asStringMap(pkgMap["parameters"])
if !ok {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read env defaults from top-level parameters

Symfony declares env defaults in the top-level parameters: section, for example parameters: { env(FOO): bar }, but this code only searches for a nested parameters map inside each package. With normal config files those defaults are skipped, so missing %env(FOO)% values resolve empty even though the project provides a default.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2df02e3b75

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

return nil, fmt.Errorf("decoding %s: %w", file.Path, err)
}

for key, value := range root {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve YAML order when merging when blocks

When a YAML file contains both a normal package key and a matching when@<env> override for the same path, iterating the decoded map[string]any makes the merge order arbitrary. If the map yields when@dev before framework, the base value is merged afterward and can overwrite the environment-specific override, so Config("dev") becomes nondeterministic for common Symfony files.

Useful? React with 👍 / 👎.

Comment on lines +115 to +118
if when := mappingChild(root, whenPrefix+environment); when != nil {
if d := mappingDepth(when, segments); d > depth {
depth = d
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep writes inside matching when blocks

Because definedDepth counts paths found under when@<env>, SetConfigValue("test", "framework.test", ...) selects a file where the existing value lives only in when@test, but set writes the path at the document root. In that case the original when@test value still overrides the write for test, and the new root value also leaks into other environments.

Useful? React with 👍 / 👎.

Comment on lines +96 to +101
files := resolveEnvFiles(projectRoot)
if len(files) == 0 {
return map[string]string{}, nil
}

return godotenv.Read(files...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load environment-specific env files

ResolvedConfig(environment) reaches this helper via Env(), but ReadAll has no environment and delegates to resolveEnvFiles, which only returns .env.dist, .env, and .env.local. For prod/test projects that put values in .env.prod or .env.prod.local, %env()% references resolve as missing or stale when asking for that environment.

Useful? React with 👍 / 👎.

return nil, ok, err
}

resolver, err := pc.newEnvResolver(nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build the single-value resolver with config defaults

This path-specific API constructs the resolver with nil config, so collectEnvParamDefaults never sees defaults declared in the merged config. A missing variable that ResolvedConfig would satisfy from env(VAR) defaults comes back as empty or as the original %env(...)% expression through GetResolvedConfigValue, making the two resolving APIs disagree for the same path.

Useful? React with 👍 / 👎.

Comment on lines +353 to +354
params, ok := asStringMap(pkgMap["parameters"])
if !ok {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read env defaults from top-level parameters

Symfony declares env defaults in the top-level parameters: section, for example parameters: { env(FOO): bar }, but this code only searches for a nested parameters map inside each package. With normal config files those defaults are skipped, so missing %env(FOO)% values resolve empty even though the project provides a default.

Useful? React with 👍 / 👎.

Soner (shyim) added a commit that referenced this pull request Jun 30, 2026
* fix(symfony): address config/packages review feedback

- Config: iterate config files in YAML document order so a when@<env>
  override that follows its base key in the same file always wins, instead
  of merging in nondeterministic map order.
- SetConfigValue: when the existing value lives in a file's when@<env>
  block, write into that block rather than the document root, so the write
  stays scoped to the environment and the when@ value no longer shadows it.
- Env resolution: layer environment-specific .env files
  (.env.<env>, .env.<env>.local) on top of the base files for
  ResolvedConfig/GetResolvedConfigValue.
- GetResolvedConfigValue: build the resolver from the merged config so it
  applies env(VAR) defaults consistently with ResolvedConfig.
- collectEnvParamDefaults: read defaults from the top-level parameters:
  section (as Symfony declares them) instead of a per-package parameters map.

Adds regression tests for each case.

* fix(symfony): write into when@<env> block on equal-depth match

When a path is defined at both the document root and the matching
when@<env> block at the same depth, the write must target the when@ block:
for that environment the when@ value is effective, so a root-level write
would stay shadowed and silently fail while leaking into other envs.

Changes definedDepth to prefer the when@<env> block on an equal-depth
match and adds a regression test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant