grasmash/yaml-expander
YAML Expander is a PHP library for recursively expanding YAML files, resolving special @import, @env, and @template tokens so configurations can be composed from multiple files and environment variables. It works well with Symfony and Drupal, supports custom expanders, and keeps large YAML configs D...
grasmash/yaml-expander is a single-purpose Composer library: it takes a YAML document containing internal property references (e.g. ${project.machine_name}) and resolves them against other keys in the same document. It is not an env-var interpolator and not a config loader — you still parse YAML yourself (it leans on symfony/yaml under the hood).
Install it like any other library:
composer require grasmash/yaml-expander
Look first at the Grasmash\YamlExpander\Expander class — it's small and has only a couple of static entry points that matter day-to-day:
Expander::expandYaml($yamlString) — takes raw YAML text, returns expanded YAML text.Expander::expandArray($array) — takes an already-parsed PHP array, returns the expanded array.Your first use case is almost always "stop copy-pasting the same value into five places in a YAML config." Example:
project:
name: acme
machine_name: acme_site
database:
name: ${project.machine_name}_db
user: ${project.machine_name}
use Grasmash\YamlExpander\Expander;
$expanded = Expander::expandYaml(file_get_contents(base_path('config/app.yml')));
$config = Symfony\Component\Yaml\Yaml::parse($expanded);
At that point database.name is acme_site_db. That's the whole mental model: expand before you parse, or call expandArray() after you parse.
Expand-then-parse for file-based config. This is the cleanest pattern and the one the library was designed for. Read the file, run it through expandYaml(), then hand the result to Yaml::parse(). You keep the raw file human-readable while downstream code sees fully resolved values.
Parse-then-expand when you're already merging. If you layer multiple YAML files (base + environment override) and merge the arrays first, call expandArray() on the merged result. Expansion after merging means an override can redefine project.machine_name once and every reference downstream picks up the new value — which is usually exactly what you want for per-environment config.
Wrap it in a small config repository, not inline calls. A dedicated class like YamlConfigLoader with a load(string $path): array method keeps the expansion detail out of your service providers. It also gives you one place to add caching, env substitution, and validation.
Do env substitution before expansion. Because the expander only resolves intra-document references, the common composition is:
$raw = file_get_contents($path);
$raw = preg_replace_callback('/\$\{env:([A-Z0-9_]+)\}/', fn ($m) => env($m[1], ''), $raw);
$config = Yaml::parse(Expander::expandYaml($raw));
Keep a distinct prefix (${env:...}) so you don't collide with the package's own reference syntax.
Use it for generated artefacts in CI. If you generate YAML for deployment (Docker Compose fragments, CI matrices, k8s manifests), running the template through expandYaml() in a build step is a low-risk way to deduplicate values without adopting a full templating language like Helm or Jinja.
Cache the expanded output, keyed on file mtime + hash. Expansion plus a Yaml::parse() is cheap but not free; for config that's read on every request, expand once at deploy time into a cache file (or a Laravel config cache entry) and skip the library at runtime.
Pair with Laravel's config cache for the final hop. A pragmatic flow: build step expands YAML → writes PHP array → php artisan config:cache picks it up as normal Laravel config. The library never runs in production request handling.
It does not read environment variables. This is the single most common source of confusion. ${FOO} will not become getenv('FOO'); it will be treated as an internal reference and either left alone or resolved to something unexpected. Do env interpolation yourself, with a distinct prefix, before calling the expander.
Order of resolution and circular references. ${a} → ${b} → ${a} will not terminate gracefully or will leave a dangling literal. Keep reference chains shallow and one-directional: define primitives at the top, derive from them below. If you find yourself chaining three levels deep, flatten it.
Values collapse to strings. Because expansion typically happens on the string YAML before parsing, a reference to an integer or boolean gets substituted textually. ${port} where port: 8080 yields the string "8080", not the integer. If consumers care about types, expand the array with expandArray() after parsing, or cast explicitly at the boundary.
Dot notation collides with literal dotted keys. ${foo.bar} means "key bar inside key foo." If you genuinely have a top-level key named foo.bar, there is no clean escape. Avoid dots in key names in any document you intend to expand.
Quoting matters in YAML. A bare ${project.name} is fine as a scalar, but once you interpolate into a larger string ("${project.name}-suffix") confirm the surrounding quotes are there, otherwise YAML may choke on the {. Also verify whether your version substitutes inside composite strings or only whole values — test it rather than assuming, since behaviour has shifted across releases.
Escaping a literal ${...}. If you need a literal reference in the output (e.g. you're generating a template for something else), you'll need to escape or post-process it. There is no documented escape sequence you can rely on across versions; substituting a placeholder token and swapping it back after expansion is the safe route.
Anchors and aliases are Symfony's job, not the expander's. YAML anchors (&base / <<: *base) are resolved during parsing. Since expansion usually happens pre-parse, references inside anchored blocks are expanded textually — fine, but be aware you're mixing two inheritance mechanisms. Pick one per file.
Debug by expanding and dumping. When a reference doesn't resolve, dd(Expander::expandYaml($raw)) shows you exactly what survived as a literal ${...}. Any remaining ${ in the output is an unresolved reference — a good assertion to add in a test or a build-time check.
Extension point is thin, so don't fight it. The library is deliberately minimal; there's no plugin system for custom resolvers. If you need conditional logic, loops, or computed values, wrap the expander — pre-process the YAML however you like, expand, then post-process. Trying to bend the reference syntax into a templating engine leads to unreadable configs.
Pin the version and check the changelog on upgrade. With a small library like this, subtle behaviour around string interpolation and multi-level resolution can change between releases. Since expansion failures tend to be silent (unresolved references pass through as literals), add a test that asserts no ${ remains in your expanded config.
How can I help you explore Laravel packages today?