YAML in PHP: Configuration, Parsers, and Untrusted-Input Safety

Use YAML as a human-friendly configuration format in PHP applications, choose between the Symfony YAML component and the yaml PECL extension, understand how permissive YAML type-coercion rules can silently change the meaning of a config value, and parse untrusted YAML safely without enabling arbitrary object instantiation.

YAML ('YAML Ain't Markup Language') shows up constantly in the PHP ecosystem even though PHP has no YAML support built into core: Symfony's routes, services, and framework config are YAML by default, Docker Compose and Kubernetes manifests are YAML, and plenty of teams pick it over JSON or INI for application config files specifically because it supports comments, multi-line strings, and anchors/references that JSON can't express. But YAML's flexibility is also its biggest trap — the same permissive grammar that lets you skip quotes around strings is the reason enabled: no can quietly become the boolean false instead of the string "no", and the same tag system that lets a schema express rich types can, in an unsafe parser, be abused to instantiate arbitrary PHP objects from a file you didn't write yourself.

Why YAML for configuration

YAML's appeal as a config format is almost entirely about what it lets humans skip: no braces, no trailing-comma errors, no mandatory quotes around simple strings, native support for comments (which JSON famously lacks), and indentation-based nesting that reads like an outline. A Symfony services.yaml or a CI pipeline's .gitlab-ci.yml leans on all of this — deeply nested configuration stays readable because YAML expresses nesting with whitespace rather than an escalating pile of closing braces.

The tradeoff is that YAML's grammar is large and has accumulated real inconsistencies across its 1.1 and 1.2 specifications (different implementations even disagree on some corner cases, notably around what counts as a boolean literal), which is part of why 'YAML looked fine in the file but parsed to the wrong value' is a genuinely common class of production config bug rather than a rare edge case.

Parser libraries: Symfony YAML vs. the yaml PECL extension

PHP has two mainstream ways to parse YAML. The symfony/yaml Composer package is a pure-PHP implementation, requires no system extension, is what Symfony itself uses internally for framework config, and is by far the more common choice in ordinary application code — install it with composer require symfony/yaml and call Symfony\Component\Yaml\Yaml::parseFile($path) or Yaml::parse($string). The yaml PECL extension is a C extension binding to libyaml, which is faster for very large documents and exposes yaml_parse() / yaml_parse_file(), but it must be compiled and enabled on the server (it is not bundled with PHP and not installed by default on most hosting), which makes it a much heavier operational dependency for what is usually a config-file-sized win.

For nearly all application configuration use cases — reading a few hundred lines of settings at boot — the performance difference between the two is irrelevant, so symfony/yaml's zero-extension-dependency portability makes it the default recommendation. Reach for the yaml PECL extension only when you are parsing large volumes of YAML at runtime (a data pipeline ingesting many YAML documents per request) where the C implementation's speed genuinely matters.

• symfony/yaml: pure PHP, no server extension required, standard choice for application config. • yaml PECL extension: libyaml binding, faster on large documents, requires compiling/enabling ext-yaml. • Both expose a 'parse a string' and a 'parse a file' entry point; both can also dump PHP data back to YAML.

Schema assumptions: YAML's implicit typing surprises

YAML has no schema of its own — every parser guesses a scalar's type from its literal text using a set of 'implicit typing' rules, and those rules are far more aggressive than most developers expect. The YAML 1.1 spec (which most parsers, including PyYAML's default loader and many older tools, still follow for boolean detection) treats yes, no, on, off, true, false, y, n — in any letter case — as boolean literals. So a config file with smtp_auth: on doesn't parse to the string "on", it parses to the boolean true, and a file with a country-code-like key such as NO: some value can even have its *key* silently converted to the boolean false under YAML 1.1 rules.

The same implicit typing applies to numbers and null: a version string like version: 1.20 can parse as the float 1.2 (losing the trailing zero and its string-ness entirely), and a bare unquoted value after a colon with nothing following it, or the literal ~, parses as null rather than an empty string. The practical rule is: any config value that must stay a string — version numbers, phone numbers, zip codes with leading zeros, anything that merely *looks* like a boolean or number — should be explicitly quoted in the YAML source (version: "1.20"), because relying on 'it obviously reads as a string to me' is exactly what produces the surprise type coercion.

Untrusted YAML is not safe by default in every parser

Beyond scalar type coercion, some YAML implementations support 'tags' that instruct the parser to construct arbitrary language-native objects, not just plain arrays/maps/scalars — this is the same class of vulnerability as PHP's own unserialize() on attacker-controlled data. In Python's PyYAML, for example, the historically default yaml.load() (without an explicit Loader=yaml.SafeLoader) could be made to instantiate arbitrary Python objects and execute code via crafted !!python/object tags; this is exactly why PyYAML now warns loudly about it and why 'always use the safe loader on untrusted input' became a standard security guideline across YAML ecosystems, PHP included.

symfony/yaml is comparatively conservative by default and does not construct arbitrary PHP objects from tags out of the box, but it does still support custom tags and, depending on flags passed to Yaml::parse(), can be configured to allow PHP object dumping/loading (Yaml::PARSE_OBJECT, Yaml::PARSE_OBJECT_FOR_MAP) or to allow PHP constants to be referenced by name in the YAML (Yaml::PARSE_CONSTANT). None of those flags are on by default, and that default-off posture is exactly what you should preserve: never pass Yaml::PARSE_OBJECT or Yaml::PARSE_CUSTOM_TAGS when parsing YAML that came from outside your own trusted deploy pipeline (a user upload, a webhook payload, a third-party plugin's config file) — treat 'parse without those flags' the same way you'd treat 'always use json_decode(), never eval()' for JSON-like untrusted data.

• Untrusted YAML = webhook payloads, user-uploaded config, third-party plugin manifests, anything not written by your own team. • Never enable Yaml::PARSE_OBJECT / PARSE_OBJECT_FOR_MAP / PARSE_CUSTOM_TAGS on YAML you don't fully control. • The default (flag-less) Yaml::parse() call is the 'safe mode' for symfony/yaml — leave it that way for untrusted input. • Quote any scalar that must stay a string to avoid yes/no/on/off and numeric-looking values silently changing type.

Parsing and dumping with symfony/yaml

Yaml::parseFile()/Yaml::parse() with no flags is the safe default — it produces only arrays, scalars, and null, never PHP objects. Wrap parse calls in try/catch for Symfony\Component\Yaml\Exception\ParseException so a malformed config file fails with a clear error instead of a fatal.

use Symfony\Component\Yaml\Yaml;

// Parse a file (throws Symfony\Component\Yaml\Exception\ParseException on bad syntax)
$config = Yaml::parseFile(__DIR__ . '/config/app.yaml');

// Parse a string
$data = Yaml::parse("name: SampleApp\nversion: \"2.4\"\n");

// Dump PHP data back to a YAML string (useful for writing generated config)
$yamlString = Yaml::dump($config, inline: 4, indent: 2);

// UNSAFE — never do this on untrusted input:
// Yaml::parse($untrustedInput, Yaml::PARSE_OBJECT | Yaml::PARSE_CUSTOM_TAGS);

Code Examples

Even with a well-formed YAML file, the code doesn't trust that scalar types survived parsing as the author intended — it explicitly casts the version to a string and normalizes the debug flag through filter_var, so an unquoted 1.20 or an unexpected on/off in the source file can't silently change application behavior. This is the same defensive habit as validating $_GET/$_POST values — a parser handing back 'the right shape' isn't the same guarantee as 'the value means what the config author intended.'

$config['app_version'] = (string) ($config['app_version'] ?? '0.0.0');

The parse call deliberately omits every flag that would let the YAML document construct PHP objects or reference constants, so a malicious webhook body can at worst produce unexpected array data — never arbitrary object instantiation. Expected result: An associative array (or list) built only from plain scalars/arrays; ParseException on malformed syntax.

Common mistake: Passing Yaml::PARSE_OBJECT (or the yaml extension's equivalent object-support settings) when parsing a payload you did not author yourself.

Contrasts an unquoted value that falls into YAML's boolean-literal rules with quoted values that force the parser to keep them as strings — the fix for implicit-typing surprises is almost always adding quotes at the source, not patching around it in PHP. Under YAML 1.1 rules (still followed by many tools), the bare tokens y/Y/yes/Yes/YES/n/N/no/No/NO/true/True/TRUE/false/False/FALSE/on/On/ON/off/Off/OFF are all boolean literals when unquoted.

<?php

declare(strict_types=1);

use Symfony\Component\Yaml\Yaml;
use Symfony\Component\Yaml\Exception\ParseException;

function loadAppConfig(string $path): array
{
    try {
        $config = Yaml::parseFile($path);
    } catch (ParseException $e) {
        throw new \RuntimeException("Invalid YAML in {$path}: {$e->getMessage()}");
    }

    if (!is_array($config)) {
        throw new \RuntimeException("Config file {$path} must parse to a mapping.");
    }

    // Defensive casts: never assume a YAML scalar kept the type you intended.
    // If app.yaml has `version: 1.20`, YAML's implicit float typing turns it
    // into 1.2 unless the author quoted it — so we normalize on read, too.
    $config['app_version'] = (string) ($config['app_version'] ?? '0.0.0');
    $config['debug'] = filter_var($config['debug'] ?? false, FILTER_VALIDATE_BOOL);

    return $config;
}
<?php

declare(strict_types=1);

use Symfony\Component\Yaml\Yaml;
use Symfony\Component\Yaml\Exception\ParseException;

function parseUntrustedWebhookYaml(string $rawBody): array
{
    try {
        // No PARSE_OBJECT / PARSE_OBJECT_FOR_MAP / PARSE_CUSTOM_TAGS flags:
        // this call can only ever produce arrays, scalars, and null.
        $data = Yaml::parse($rawBody);
    } catch (ParseException) {
        throw new \InvalidArgumentException('Malformed YAML payload.');
    }

    if (!is_array($data)) {
        throw new \InvalidArgumentException('Payload must be a YAML mapping or sequence.');
    }

    return $data;
}
# Unquoted values below are parsed with YAML's implicit typing rules.
app_name: SampleApp

# DANGER: unquoted -> parses as boolean true, not the string "on"
smtp_auth: on

# SAFE: quoted -> stays the string "1.20" instead of becoming the float 1.2
version: "1.20"

# SAFE: quoted -> stays the string "no" for a country code, not boolean false
country_code: "NO"

features:
  - search
  - practice-compiler
  - dark-mode

Why 'safe mode' became the default conversation about YAML

The pattern of 'YAML parser defaults to allowing arbitrary object construction, gets exploited, ecosystem adds a documented safe loader' has played out across multiple languages (most visibly with Python's PyYAML). PHP's ecosystem largely avoided that specific CVE history because symfony/yaml never made object construction the default — but the lesson generalizes: any format with a tag or type-annotation system that can be interpreted as 'construct this class' needs the same untrusted-input discipline as deserialization functions, and 'parse with the plainest possible mode' is the safe default to reach for first, opting into richer features only for files you control.

$data = Yaml::parse($_POST['config_yaml'], Yaml::PARSE_OBJECT | Yaml::PARSE_CUSTOM_TAGS);

$data = Yaml::parse($_POST['config_yaml']); // no object/tag flags on untrusted input

PARSE_OBJECT and PARSE_CUSTOM_TAGS allow the YAML document itself to direct PHP object construction — enabling either on user-submitted YAML hands an attacker a way to influence object instantiation from a config string, the same category of risk as unserialize() on untrusted data.

In a YAML config file, what does an unquoted value of on typically parse to? A. The string "on", exactly as written B. The boolean true, under YAML's implicit boolean-literal typing rules C. A parse error, since on is not valid YAML D. An integer 1

B. The boolean true, under YAML's implicit boolean-literal typing rules — YAML (especially under 1.1-style implicit typing followed by many parsers) treats yes/no/on/off/true/false as boolean literals when unquoted, so on becomes the boolean true rather than the string "on" — quote it if you need the literal string.

What is the main risk of enabling Symfony YAML's PARSE_OBJECT flag when parsing untrusted input? A. It makes parsing slower B. It disables comments in the YAML file C. It allows the YAML document to direct construction of PHP objects, which is dangerous when the document comes from an untrusted source D. It forces all keys to be strings

C. It allows the YAML document to direct construction of PHP objects, which is dangerous when the document comes from an untrusted source — PARSE_OBJECT (and related flags like PARSE_OBJECT_FOR_MAP/PARSE_CUSTOM_TAGS) let the YAML content itself influence PHP object instantiation, which is unsafe on any YAML you did not author — the same risk category as PHP's unserialize() on attacker-controlled data.

Write a function loadTrustedConfig(string $path): array that loads a YAML config file with symfony/yaml, throws a clear exception on parse errors or a non-mapping root, and normalizes an enabled key through filter_var(..., FILTER_VALIDATE_BOOL) regardless of whether the YAML author wrote true/false, yes/no, or on/off. Then write a second function parseUntrustedYaml(string $raw): array for webhook input that never enables PARSE_OBJECT-family flags. Hint: filter_var() with FILTER_VALIDATE_BOOL already understands 'yes'/'no'/'on'/'off'/'true'/'false' strings, which makes it a good normalization step after YAML's own (sometimes inconsistent) boolean coercion.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.