pilotphp / config
Deterministic compiled configuration for the PilotPHP Agent-First framework.
Requires
- php: ^8.5
- pilotphp/contracts: ^0.0.2
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.75
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.5
This package is not auto-updated.
Last update: 2026-07-28 00:10:33 UTC
README
Deterministic compiled configuration for the PilotPHP Agent-First framework.
Configuration is assembled and compiled before requests are served. In the worker, reading a value is one hash lookup — no file access, no environment access, no merging.
Installation
composer require pilotphp/config
Requires PHP 8.5. Depends on nothing but PHP and pilotphp/contracts.
What it does
- Loads configuration from trusted PHP files, directories, and arrays
- Merges ordered layers deterministically, refusing ambiguity
- Keeps external values (secrets, environment) as unresolved references
- Compiles to a byte-reproducible PHP artifact with a SHA-256 fingerprint
- Serves an immutable, typed
ConfigInterfaceat runtime
What it does not do
No DI container, kernel, runtime loop, RoadRunner, gRPC, HTTP, ORM,
Blueprint, console commands, .env parsing, YAML/XML, secret-provider
implementations, or Composer package scanning. Each belongs to its own
package — see docs/architecture.md.
Development usage
use PilotPHP\Config\Build\{ConfigBuilder, ConfigLayer, ConfigLayerKind};
use PilotPHP\Config\Runtime\ImmutableConfig;
use PilotPHP\Config\Source\PhpFileConfigSource;
use PilotPHP\Config\Value\{ConfigValueResolver, EnvironmentValueResolver};
$result = new ConfigBuilder()
->addLayer(new ConfigLayer(
id: 'framework',
kind: ConfigLayerKind::FrameworkDefaults,
data: ['app' => ['name' => 'unnamed', 'workers' => 1]],
))
->addSource(new PhpFileConfigSource(
path: __DIR__ . '/config/app.php',
id: 'application',
kind: ConfigLayerKind::Application,
))
->build();
$config = new ImmutableConfig(
new ConfigValueResolver([new EnvironmentValueResolver($environment)])->resolve($result->data()),
);
$config->getString('app.name');
$config->getInt('app.workers');
Production pipeline
Build and compile once, at deploy time:
use PilotPHP\Config\Compiler\PhpConfigCompiler;
new PhpConfigCompiler()->compile($result, __DIR__ . '/var/cache/config.php');
Worker boot
Then every worker boots from that one file:
use PilotPHP\Config\Compiler\CompiledConfigLoader;
use PilotPHP\Config\Runtime\ImmutableConfig;
use PilotPHP\Config\Value\{ConfigValueResolver, EnvironmentValueResolver};
$payload = new CompiledConfigLoader()->load(__DIR__ . '/var/cache/config.php');
$resolved = new ConfigValueResolver([
new EnvironmentValueResolver($environment),
])->resolve($payload->data());
$config = new ImmutableConfig($resolved);
Three steps, once per process. Afterwards $config never touches the disk
again. Both pipelines produce identical configuration — the integration
suite asserts it.
Layers
| Kind | Value | Purpose |
|---|---|---|
FrameworkDefaults | 100 | framework baseline |
PackageDefaults | 200 | installed packages' defaults |
Application | 300 | the application's own config |
Environment | 400 | per-deployment overrides |
Runtime | 500 | programmatic overrides, applied last |
Sorted by kind, then order, then layer id — a total ordering, so the
result never depends on the order sources were added.
Merging
Maps merge recursively; lists are replaced wholesale; a scalar replaces a scalar of the same type. Anything ambiguous is refused and must be stated explicitly:
use PilotPHP\Config\Merge\ConfigMerge;
return [
'servers' => ConfigMerge::append(['replica-3']), // extend a list
'features' => ConfigMerge::replace(['a' => true]), // override any shape
'legacy' => ConfigMerge::remove(), // delete a key
'timeout' => ConfigMerge::replace(1.5), // int → float is a conflict
];
Removing a key that does not exist is an error, not a no-op — that is how a typo in an override file gets caught. Full table: docs/merge-semantics.md.
Environment references
use PilotPHP\Config\Value\{DeferredValue, DeferredValueType};
return [
'database' => [
'host' => DeferredValue::environment('DB_HOST', default: 'localhost'),
'port' => DeferredValue::environment('DB_PORT', DeferredValueType::Integer, default: 5432),
'password' => DeferredValue::environment('DB_PASSWORD', required: true),
],
];
These stay unresolved through merging, fingerprinting, and compilation, and are resolved once at boot. A secret therefore cannot reach the compiled artifact or the fingerprint.
required and default are mutually exclusive — the combination has no
coherent meaning and is rejected at construction.
Casts are strict: DB_PORT=8O80 fails the boot loudly instead of quietly
becoming 8. See docs/deferred-values.md.
Package defaults
A package declares where its defaults live; the application overrides them:
$registration->add(new ConfigDefaultsDeclaration(
prefix: 'grpc',
path: 'config/default.php',
));
See docs/package-integration.md.
Limitations
- Configuration files are trusted code. They are
required and may compute values. Loading configuration from an untrusted source is out of scope. - Side effects in configuration files are unsupported; a file is executed once per build, at an unspecified time.
- Config map keys may not contain a dot (dots separate path segments) and
may not start with
__pilot_(reserved). []is a list as far as PHP is concerned, so an empty array merging into a map is a shape conflict. UseConfigMerge::replace().- The flat runtime index trades memory for O(1) reads; see docs/performance.md.
Development
make install
make check # validate + cs-check + analyse + test
make benchmark # measurement only, never a blocking test
Documentation
| Document | Contents |
|---|---|
| architecture.md | Pipeline, boundaries, trust model |
| merge-semantics.md | Complete merge table |
| deferred-values.md | Providers and secret safety |
| compilation.md | Deterministic artifacts |
| package-integration.md | Package defaults |
| performance.md | Hot-path policy and measurements |
| AGENTS.md | Rules for changing this package |
Repository
A standalone Git repository and Composer package. PilotPHP has no monorepo; every package is its own repository.