waffle-commons / config
Config component for Waffle framework.
Requires
- php: ^8.5
- ext-yaml: *
- waffle-commons/contracts: 0.1.0-beta2.1
Requires (Dev)
- carthage-software/mago: ^1.29
- cyclonedx/cyclonedx-php-composer: ^6.2
- php-mock/php-mock-phpunit: ^2.15
- phpunit/phpunit: ^12.5
- vimeo/psalm: ^6.16
This package is auto-updated.
Last update: 2026-05-30 19:47:01 UTC
README
Waffle Config Component
Release:
v0.1.0-beta2|CHANGELOG.md| Beta-1 hardening retained: no process-env mutation PHP extension required:ext-yaml(the native PECL YAML extension — not Symfony/yaml userland)
A strict, typed-getter configuration loader. Reads YAML via the native ext-yaml extension with yaml.decode_php = 0, eliminating the PHP-deserialisation gadget surface that comes with userland parsers. Environment-specific overlays are applied via array_replace_recursive, and %env(VAR)% placeholders are resolved at load time against a read-only env registry injected through the constructor — never against getenv() or $_ENV directly (Beta 1 hardening for FrankenPHP worker-mode safety).
📦 Installation
composer require waffle-commons/config
ext-yaml must be available in the PHP runtime. The waffle-dev Docker image ships with it pre-installed.
🧱 Surface
| Class | Role |
|---|---|
Waffle\Commons\Config\Config |
final implementation of ConfigInterface. Typed getters: getInt, getString, getArray, getBool. Accepts a constructor array $env = [] registry consulted for %env(VAR)% resolution. |
Waffle\Commons\Config\YamlParser |
final parser wrapper around yaml_parse_file() with safe defaults. |
Waffle\Commons\Config\DotEnv |
Beta 1: pure .env / .env.local parser. load(): array<string,string> returns the parsed map; no longer mutates putenv(), $_ENV, or $_SERVER. |
Waffle\Commons\Config\Trait\ParserTrait |
Shared parse helpers. |
Waffle\Commons\Config\Exception\InvalidConfigurationException |
Thrown when a key resolves to a value of the wrong type. |
🚀 Usage
use Waffle\Commons\Config\Config; use Waffle\Commons\Config\DotEnv; use Waffle\Commons\Contracts\Enum\Failsafe; // Build the env registry from .env + process env (rightmost wins → OS beats .env). $envRegistry = array_merge( (new DotEnv(__DIR__))->load(), getenv() ?: [], ); $config = new Config( configDir: __DIR__ . '/config', environment: 'prod', failsafe: Failsafe::DISABLED, env: $envRegistry, ); $port = $config->getInt('http.port', default: 8080); $debug = $config->getBool('app.debug', default: false); $logs = $config->getArray('logging.channels', default: []); $appName = $config->getString('app.name');
The constructor signature, verbatim from src/Config.php:
/** * @param array<string, string> $env Read-only env registry consulted when * resolving `%env(VAR)%` placeholders. Defaults to an empty map. */ public function __construct( string $configDir, string $environment, Failsafe $failsafe = Failsafe::DISABLED, array $env = [], )
📁 File layout
config/
├── app.yaml # base, always loaded
├── app_dev.yaml # environment overlay (applied if env = "dev")
├── app_prod.yaml # environment overlay
└── app_test.yaml # environment overlay
The base file is loaded first. Then app_{environment}.yaml is loaded if it exists, and merged on top of the base via array_replace_recursive. %env(VAR_NAME)% placeholders anywhere in the resolved tree are expanded against the constructor-injected $env registry (see Environment registry below).
🌱 Environment registry (Beta 1)
Beta 1 removes all process-env mutation from this component. The contract is now:
DotEnv::load(): array<string,string>— pure file parser. Reads.envand.env.local(first file wins on conflict) and returns the parsed map. Boolean-typed keys (APP_DEBUG,DEBUG) are validated + normalized to'1'/'0'; anything else for those keys throwsInvalidArgumentException. No globals are mutated.Config(..., array $env = [])— the caller builds the env registry and injects it.%env(VAR)%resolution reads from$this->env[$name] ?? null— never fromgetenv(),$_ENV, or$_SERVER.
Canonical wiring
$envRegistry = array_merge( (new DotEnv($root))->load(), // left: .env / .env.local getenv() ?: [], // right: OS / Docker / K8s );
array_merge is rightmost-wins on string keys, so the process environment beats .env on collision. This matches the Twelve-Factor convention and the implicit precedence of the legacy DotEnv (which silently skipped any key already in $_ENV / $_SERVER). Flip the order to make .env win.
Type-normalization asymmetry. DotEnv normalizes
APP_DEBUG/DEBUGbooleans;getenv()does not. SoAPP_DEBUG=yesin.envbecomes'1', but the same value exported by the OS becomes'yes'— which then failsConfig::getBool('app.debug')if the YAML uses'%env(APP_DEBUG)%'. Either export canonicaltrue/falsevalues, or normalize$processEnvbefore merging, or use YAML boolean literals instead of%env()%for bool keys.
See the how-to guide and the reference doc for the full discussion.
🛟 Failsafe mode
When Failsafe::ENABLED is passed, Config skips file loading and seeds a minimal default tree (waffle.security.level = 1). This is used by the ErrorHandlerMiddleware boot path so that even a totally broken config still allows the error renderer to run.
🐘 PHP 8.5 features used
- Typed getters with
?int/?string/?array/?boolreturn types. final class Configandfinal class YamlParser— no subclassing.- Constructor property promotion.
Failsafeis an enum fromWaffle\Commons\Contracts\Enum\Failsafe— backed-string semantics for safe defaulting.
🧭 Architectural boundary (mago guard)
An active dependency perimeter is enforced on every CI run by vendor/bin/mago guard (bundled into composer mago; zero baselines). The rules live in mago.toml under [guard.perimeter] — a forbidden use statement fails the build, not a reviewer.
Production code under Waffle\Commons\Config may depend only on:
Waffle\Commons\Config\**— itselfWaffle\Commons\Contracts\**— the shared contracts package, the only Waffle dependency permittedPsr\**— PSR interfaces@global+Psl\**— PHP core (includingext-yaml) and the PHP Standard Library
Test code under WaffleTests\Commons\Config is unrestricted (@all). Structural rules are guarded too: interfaces must be named *Interface, Exception\** classes must end in *Exception, and any Enum\** namespace may hold only enum declarations.
Contract-first, component-agnostic by construction: components compose through waffle-commons/contracts, never directly through one another.
🧪 Testing
docker exec -w /waffle-commons/config waffle-dev composer tests
📄 License
MIT — see LICENSE.md.