symfony/yaml
The Symfony Yaml component parses YAML files into PHP arrays and dumps PHP data back to YAML, with support for inline syntax, custom flags, and clear parse exceptions. A lightweight, dependency-free way to handle configuration and fixtures.
symfony/yaml is a standalone component with a static API — no service provider, no facade, no config file. Laravel does not ship it, so add it explicitly:
composer require symfony/yaml
Everything happens through two static methods:
use Symfony\Component\Yaml\Yaml;
$data = Yaml::parseFile(base_path('content/pages.yaml')); // string|array|null
$yaml = Yaml::dump(['title' => 'Home', 'tags' => ['a', 'b']]);
Where to look first:
https://symfony.com/doc/current/components/yaml.html — the canonical docs (the README is just an index).vendor/symfony/yaml/Yaml.php — the flag constants (PARSE_CONSTANT, PARSE_CUSTOM_TAGS, DUMP_MULTI_LINE_LITERAL_BLOCK, DUMP_EMPTY_ARRAY_AS_SEQUENCE, DUMP_NUMERIC_KEY_AS_STRING, DUMP_NULL_AS_EMPTY, …) are the real "features" of this package and are only visible in source.vendor/symfony/yaml/Exception/ParseException.php — getParsedLine() and getSnippet() are your debugging tools.First real use case: reading data that something else owns the format for — a manifest, an openapi.yaml, a docker-compose file, a CI workflow, a third-party import file, or seed/fixture data you want humans to edit. Do not migrate your config/*.php files to YAML; config:cache and the rest of Laravel assume PHP.
Sanity check in tinker:
>>> Yaml::parse("a: 1\nb: [x, y]\n")
=> ["a" => 1, "b" => ["x", "y"]]
config() via a service providerThe cleanest integration if the YAML is application-owned and static:
// app/Providers/YamlConfigServiceProvider.php
public function register(): void
{
$path = config_path('features.yaml');
if (is_file($path)) {
// values set here are baked into bootstrap/cache/config.php by config:cache
$this->app['config']->set('features', Yaml::parseFile($path) ?? []);
}
}
Once set, config('features.new_checkout.enabled') works like any other config key, and php artisan config:cache freezes it — which is usually exactly what you want.
Parsing is regex-heavy pure PHP; re-parsing per request is wasted CPU. Key the cache on filemtime():
class YamlRepository
{
public function get(string $path, array $default = []): array
{
$abs = base_path($path);
$key = 'yaml:'.md5($abs).':'.@filemtime($abs);
return Cache::rememberForever($key, fn () => Yaml::parseFile($abs) ?? $default);
}
}
Bind it as a singleton and inject it; this is your extension point since the component itself has none.
YAML is untyped and hand-edited — never trust its shape. Treat the parse result as request input:
$parsed = Yaml::parseFile($path);
$data = Validator::make($parsed ?? [], [
'name' => ['required', 'string', 'max:120'],
'steps' => ['required', 'array', 'min:1'],
'steps.*.id'=> ['required', 'string', 'distinct'],
])->validate();
ParseException into something Laravel-shapeduse Symfony\Component\Yaml\Exception\ParseException;
try {
$data = Yaml::parseFile($path);
} catch (ParseException $e) {
throw ValidationException::withMessages([
'file' => sprintf('%s (line %d)', $e->getMessage(), $e->getParsedLine()),
]);
// $e->getSnippet() gives the surrounding lines for logs
}
parseFile() is local-only. For an S3 disk, Storage::disk('s3')->get($path) then Yaml::parse().
Defaults ($inline = 2, $indent = 4) surprise people who expect 2-space YAML:
$yaml = Yaml::dump(
$data,
10, // expand deeply — fewer inline `{ }` blobs
2, // 2-space indent, matches most external tooling
Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK
| Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE
| Yaml::DUMP_NUMERIC_KEY_AS_STRING
);
Typical for "update this manifest" tasks:
$doc = Yaml::parseFile($path) ?? [];
$doc['image'] = "app:{$tag}";
File::put($path, Yaml::dump($doc, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK));
For git-friendly diffs, normalise key order first with a recursive ksort() — the dumper uses insertion order, so unsorted arrays produce noisy commits.
// php artisan import:yaml database/data/products.yaml
$rows = Yaml::parseFile($this->argument('file')) ?? [];
For tests, YAML data providers are genuinely nice for long fixture arrays:
public static function pricingCases(): array
{
return collect(Yaml::parseFile(__DIR__.'/fixtures/pricing.yaml'))
->map(fn ($case) => [$case['input'], $case['expected']])
->all();
}
It's static, so it's untestable to mock. Yaml::parseFile() is a static call — wrap it in your own YamlRepository/YamlLoader interface if you need to stub it or swap libraries later. There is no DI extension point inside the component.
Reserved indicators must be quoted. A value starting with %, @, `, !, &, *, -, ?, or : throws ParseException. This bites constantly with emails/mentions (@handle), and with Symfony parameter syntax (%env(...)%). Quote them:
handle: '@acme' # ❌ ParseException
handle: "'@acme'" # ✅ (or just '@acme' in double quotes: "@acme")
Booleans are true/false only. Symfony follows YAML 1.2: yes, no, on, off, y, n parse as strings. null, Null, NULL, ~, and an empty value parse as null. If external tools emit yes, convert explicitly.
Numeric-looking strings round-trip badly. version: 1.10 becomes 1.1; zip: 01234 becomes 1234. Quote version numbers and anything with leading zeros. Also: integers outside PHP_INT_MIN..MAX are now kept as strings (fixed in 6.4.26+) — don't (int) cast large IDs blindly.
Dates are not DateTime and not timestamps. The old PARSE_DATETIME / PARSE_OBJECT / PARSE_OBJECT_FOR_MAP flags are gone in Symfony 7. Cast and validate dates yourself, and quote them so the dumper doesn't reformat them.
Empty arrays and numeric keys are ambiguous. Yaml::dump([]) emits { }, which is an empty map but parses back to [] — fine until an external tool cares. Use DUMP_EMPTY_ARRAY_AS_SEQUENCE when you need []. PHP arrays with sequential int keys dump as sequences; if you truly need a numeric-keyed map, use DUMP_NUMERIC_KEY_AS_STRING or a JSON string payload.
No comments, no anchors, no formatting fidelity. Dumping expands aliases, drops comments, and normalises style. If you need to edit a user's YAML preserving their comments, round-tripping with this component will destroy them — keep those files as opaque templates or use a comment-preserving library.
Single document only. Don't rely on ----separated multi-document streams; split the file yourself before parsing.
Tabs are illegal for indentation. Recent releases fixed tabs inside block scalars and comments after empty ones, but indentation must be spaces. This is the #1 cause of ParseException on files pasted from editors.
Debugging a parse error: the message names the line; add $e->getSnippet() to logs to see the surrounding text. A quick bisect is parsing progressively larger prefixes of the file.
Security — treat YAML as untrusted code-adjacent input.
PARSE_CUSTOM_TAGS or accept !php/object on user-supplied files — that path calls unserialize() (there's a PHPStan rule for it upstream, which tells you how often it goes wrong). Avoid !php/const / PARSE_CONSTANT too.^7.4.12 or newer, or ^8.1 on PHP 8.4.max: file rule) and, if you accept arbitrary YAML, cap nesting depth by validating the resulting array shape.Version constraints matter. Composer resolves 6.4 / 7.x / 8.x depending on your PHP version, and the parse behaviour of edge cases (block scalars, + prefixed ints, NaN, 0X prefixes) changed across patch releases. Pin a major in composer.json if generated YAML is compared byte-for-byte in CI, and upgrade deliberately for the security patches.
Don't assert exact dump strings in tests. assertSame($expectedYaml, Yaml::dump($data)) is brittle across patch releases. Prefer round-trip assertions — but remember round-tripping is lossy for objects, DateTime, numeric-keyed maps, and 1.0 vs 1, so assert the semantic subset you care about.
Reach for JSON on hot paths. If a file is machine-generated and machine-consumed, json_decode() is meaningfully faster than the YAML parser. YAML earns its cost when humans edit the file.
How can I help you explore Laravel packages today?