joetjen / cooper
Under heavy development: not for production use before 1.0.0. Loads CASC config files -- a hierarchical, extensible config language with imports, variables, interpolation, loops, and consumer-registered resolvers/tags -- into native PHP values, secrets redacted by default.
Requires
- php: ^8.2
- ext-bcmath: *
- ext-mbstring: *
- vlucas/phpdotenv: ^5.6
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Warning
Under heavy development: not for production use before 1.0.0.
Things can and will break, and the specification can and will change,
between any two releases until 1.0.0. Do not use this library in a
production system yet.
A PHP port of cooper, the Elixir
loader for CASC config files -- a hierarchical,
extensible config language with imports, variables, string
interpolation, environment/config references, loops, and
consumer-registered resolvers and tags -- into native PHP values.
# config.casc
#@version = 1.0
@region = "eu-west"
server {
host = "0.0.0.0"
port = 8080
}
database {
*password = ${DB_PASSWORD}
host = "db.@{region}.internal"
pool_size = 10
timeout = 500ms
}
use JOetjen\Cooper\Cooper; $config = Cooper::loadFile('config.casc', ['env' => ['DB_PASSWORD' => 'hunter2']]); $config['server']['port']; // 8080 $config['database']['host']; // "db.eu-west.internal" (string) $config['database']['password']; // "[~~REDACTED~~]" $config['database']['password']->reveal(); // "hunter2" $config['database']['timeout']->nanoseconds; // 500000000
Status: feature-complete, pre-1.0. The whole CASC language as the
Elixir original implements it -- every value type, imports (globs,
braces, scheme:// loaders, ${NAME} in a path), public/private
variables, loops, merge sigils, all five reference forms with suffixes
and filters, built-in and consumer tags, secrets with partial
redaction -- plus .env layering and file caching. See
CHANGELOG.md.
Why use Cooper?
Configuration starts as a file of values and grows: a host written in three places, a port that differs per environment, a password that must never reach a log, a second file for what two services share. CASC is a configuration language for that point. It stays data, not a program, and adds the few things configuration keeps needing:
- References instead of repetition.
%{server.port}points at another key, resolved against the final, merged configuration, so it follows that key wherever an import or override puts it.@{region}reads a variable. - The environment, with defaults and checks.
${PORT:8080}falls back,${DB_URL:?"set DB_URL"}refuses to load without it, and${NAME | trim}normalises it..envfiles are read for you. - Imports and layering. Split a document into files, import a glob
of them, and override with explicit sigils:
+tagsappends,-tagsremoves,~tls { ... }replaces instead of merging. - Secrets marked where they are written.
*password = ...comes back wrapped, and is redacted wherever it is printed or logged -- including inside a longer string built from it. - The types configuration uses. Durations (
500ms), byte sizes (512MiB), IP addresses and networks, dates and times, atoms and tuples arrive as values, not strings to parse later. - Extension points you control. Tags (
!duration("5m")) and resolvers (!{vault:secret/db}) your application registers; an unregistered one is a load error, never passed through silently. - One language, four implementations. Elixir, Node, PHP and Praxis read the same file into the same structure. CASC has a written specification (CASC.md), and the implementations are held to one shared conformance suite, so a document means the same thing wherever it is read.
Compared with other configuration languages
| JSON | YAML | TOML | INI / .env |
CASC | |
|---|---|---|---|---|---|
| Comments | no | yes | yes | yes | yes |
| References to other keys | no | anchors, within one document | no | some .env loaders expand ${VAR} |
%{path}, against the merged result |
| Environment variables | no | left to the loader | no | it is the environment | ${NAME}, with defaults, checks and filters |
| Imports | no | left to the loader | no | no | files, globs, custom schemes |
| Values beyond strings and numbers | no | dates, and implicit typing (no can become false) |
dates and times | no | durations, sizes, addresses, dates, tuples |
| Secrets | no | no | no | no | marked with *, redacted |
HCL comes closest in expressiveness, with expressions and
interpolation, but much of what makes it useful -- variables, modules,
sensitive -- belongs to the tool that embeds it, Terraform above all.
Jsonnet, Dhall, CUE and Pkl go further than CASC: they are
programmable, with functions and, in CUE, Dhall and Pkl, types and
schemas. CASC deliberately stops short of that. It has loops, but no
functions and no schemas, so a document stays something a person reads
top to bottom.
Compared with each language's usual configuration
| Language | Usually | With Cooper |
|---|---|---|
| Elixir | config/*.exs: Elixir code, split between compile time and runtime.exs, reading the environment with System.get_env/1 |
cooper reads CASC; cooper_config replaces Mix's config sources -- config/runtime.exs through Config.Provider in a release |
| Node | JSON or YAML files and dotenv, often under a library such as config or convict |
@joetjen/cooper reads CASC; @joetjen/cooper-config loads it once at startup; @joetjen/cooper-fastify and @joetjen/cooper-nestjs hand it to Fastify and to @nestjs/config |
| PHP | PHP arrays and YAML: Laravel's config/*.php calling env(), Symfony's config/packages/*.yaml with %env()% |
joetjen/cooper reads CASC; joetjen/cooper-config loads it once at startup; joetjen/cooper-laravel replaces config/*.php, joetjen/cooper-symfony replaces bundle configuration and container parameters |
| Praxis | CASC is its configuration language: config/config.casc |
cooper reads it; cooper_prx loads it before the program starts and hands it to praxis.app |
Every one of them also reads .env files, the same way. So a service
in PHP and a worker in Node can share one configuration document and
read it identically.
When something else fits better
If a handful of values is all there is, .env alone is enough. If
configuration has to be computed -- functions, generated structures --
or checked against a schema before it is used, Jsonnet, CUE, Dhall or
Pkl do what CASC does not try to.
The value model
Blocks/maps come back as associative arrays with string keys, lists
as list arrays. Everything a PHP array cannot represent faithfully
gets a small value class under JOetjen\Cooper\Value, following the
Cooper-prefixed naming this user's PHP libraries share:
| CASC | PHP |
|---|---|
| block / map | array (string keys; PHP turns "0" into 0) |
list [...] |
array (a list) |
tuple (...) |
CooperTuple -- never a list, CASC.md §6.11 |
atom info, :info |
CooperAtom (interned: === CooperAtom::of('info')) |
nil, true, inf (and :nil, :true, :false) |
null, bool, INF/-INF |
| integer, float | int, or CooperInteger beyond 64 bits; float |
500ms, 1h30m |
CooperDuration (nanoseconds: int, or CooperInteger beyond 64 bits) |
512MiB, 10GB |
CooperBytes (bytes: int, or CooperInteger beyond 64 bits) |
127.0.0.1/8, ::1 |
CooperIPv4, CooperIPv6 (CIDR math included) |
1979-05-27T07:32:00Z |
CooperDateTime, in UTC, fractional digits as written; toDateTimeImmutable(?tz) |
1979-05-27T07:32:00 / 1979-05-27 / 07:32:00 |
CooperLocalDateTime / CooperDate / CooperTime |
*key = ... |
CooperSecret around the value |
Secrets redact themselves in string conversion, json_encode(),
var_dump(), and print_r(); reveal() is the one deliberate way
to the real value. The wrapping travels with the value through every
%{...} copy, and a secret interpolated into a larger string redacts
only its own portion.
Where this port differs from the Elixir original
PHP-specific choices:
- Exceptions, not tuples. Every failure throws
CooperError, whose$stagekeeps the Elixir vocabulary (parser,import,resolve, ...) and which carries the CASC source line/column/file when known. - Callbacks return or throw. A resolver, tag, or import-scheme
callable returns its value directly and signals failure by throwing,
instead of
{:ok, v}/{:error, reason}. - Integers are a native
intwhen they fit in 64 bits, and aCooperInteger(every digit kept,bcmath-backed, as php-dextrin's) beyond -- the Elixir original's are arbitrary-precision throughout. A duration's or byte size's count follows the same rule. !module("Acme.Payments")yields a PHP class-string,"Acme\Payments": the name is written the same way for every Cooper implementation -- dot-separated PascalCase, anything else a load error -- and each translates it into its own convention (CASC.md §7.5). Themodulesoption maps a name exactly as written to whatever!moduleshould give back for it instead (['modules' => ['Store' => RedisStore::class]]); the class is never loaded or checked. Interpolated into a string, a module reads as the name the document wrote, as in every implementation (§7):"x-!module(\"Foo.Bar\")"isx-Foo.Bar, notx-Foo\Bar; a mapped one reads as the value it maps to..envsupport parses withvlucas/phpdotenv, a dependency of Cooper's (the Elixir original usesdotenvy). The.env.<env>file is named byCOOPER_ENV, as in every Cooper:.env.dev,.env.staging,.env.test,.env.prod-- so Laravel'sAPP_ENV=productionreads.env.prod, never.env.production. The files are read fromdotenvDir, by default the project root: the Composer root package's directory (the Elixir original: the Mix project's), else the working directory.Dotenv::export()has no Elixir counterpart and is not CASC behaviour: it exists for framework integrations that replace the framework's own.envloader. Called explicitly -- never by a load, never by default -- it writes the names the.envfiles define, andCOOPER_ENV, into$_ENV,$_SERVERandputenv(), with the value${NAME}resolves to, so code reading the process environment directly (Laravel'senv(), Symfony'sAPP_ENV) sees them too. A variable the real environment already has is never overwritten, unlessdotenvOverrideis set; it returns what it wrote, and calling it twice changes nothing more.${COOPER_ENV}is always set (CASC.md §7.2): a realCOOPER_ENVwins; unset or empty, it falls back toAPP_ENV-- read from the same layers as every other name, so a.envfile setting it counts -- else"dev". The fallback is mapped onto the names every Cooper uses (dev,staging,test,prod) through CASC.md §7.2's one table, so Laravel'sAPP_ENVvalues work as they are:developmentandlocalbecomedev,testingbecomestest,productionbecomesprod, and anything else (staging,qa, ...) is passed through. A realCOOPER_ENVis never mapped. The Elixir original falls back toMIX_ENV, then the liveMix.env/0.Cooper::loadFileTraced()has no Elixir counterpart: it returns the config together with every file the load read and every environment variable it read (with the value it saw), for a caller that keeps the result whereCachecannot see it -- a compiled PHP file, which shared-nothing PHP wants, is the reason it exists.- The
envValueload option has no Elixir counterpart: it lets a host decide what a${NAME}written as a value resolves to (seeEnvReference) -- for a framework that compiles configuration into a cache and must keep the environment a runtime placeholder (joetjen/cooper-symfonyturns it into Symfony's%env(NAME)%). PHP's frameworks compile their configuration; the BEAM's read it at boot. LiteralTaghas no Elixir counterpart: a tag registered asnew LiteralTag($fn)takes only a string literal written in the document, and the load fails -- naming the tag, the key and the file -- when its argument is anything else (${...},@{...},%{...}, another tag, an interpolating string). It is for a framework integration's tag that runs its argument as code (joetjen/cooper-laravel's!php("""...""")), so that no value from the environment can ever become code. Host-specific integration behaviour, not CASC's: a plain callable tag takes any argument.- Caching lives in process memory, and a tree-shaping environment
variable (a
${?NAME}guard,${NAME}in an import path) is checked on every load instead of by a background poller -- so a guard's decision is never stale. Change events go toCache::listen()callbacks instead of:telemetry.
Porting the Elixir original also found ten places where it (as of 0.4.0) contradicted CASC.md, and the corpus of conformance cases shared by every Cooper implementation (since retired for the Cooper Conformance Suite) found fourteen more. All are fixed in the Elixir original's 0.5.0, which is the reference this port matches case for case:
foo "bar"-- an assignment without=to a string, §5.3's own example -- is an assignment, not a failedimport.- A
#-disabled statement (§5.6) has no effect at all. - A merge sigil inside a block keeps its meaning (
a { +tags = [...] }appends,a { -b }deletes). - A bare
-key.pathfollowed by another complete statement is a delete (the rule CASC.md §5.7 now states), and+info = [...]appends toinfo. - An interpolated key (
"region-@{name}" = ..., §4.2) resolves outsideforloops, from@{...}and${...}, and must be a non-empty string with no.inside one too; a loop binding substitutes into a%{...}path, a default, and a filter argument, and keeps the reference's filters, index, and suffix (@{x | upcase}); a~key { }in a loop body clears under the generated destination. - Interpolating a list, map, or tuple is a clean
resolveerror; an impossible date (2023-02-30) a clean load error. - A
${NAME}in an import path counts as tree-shaping, like a guard. - A value inside a string reads as CASC writes it (§7):
inf/-inf,nil, every digit of a large integer. ${PORT:+1}substitutes1;${PORT:-1}is a default of minus one.- A private variable reads another private one, and shadows an imported public one of the same name in its own file only (§5.2).
+key = @{list}appends the list's elements wherever@listis declared, and+/-in afor ... frombody edit the template's copy (§5.5, §8.4);+key = nilappendsnil,-key = nilremoves it, and removal is strict (1is not1.0).- A duration takes
_digit separators (1_000ms, §6.8). - A secret can be filtered (
%{pw | trim}) and stays a secret. - A comment may come before the
#@versionheader.
Backslash-continued strings (§6.5) remain unimplemented here, as in the Elixir original -- the spec gives no worked example to validate against.
Documentation
- QUICKSTART.md -- install and load a first file.
- TUTORIAL.md -- the library and the CASC syntax it parses, built up one feature at a time.
- EXAMPLES.md -- secrets managers, per-environment overlays, testing config without disk or network, CIDR allowlists.
- CHEATSHEET.md -- the public API and every option.
- casc/ -- the implementation-independent CASC specification (CASC.md), tutorial, examples, and cheatsheet, shared with the Elixir original.
- API documentation -- generated with phpDocumentor.
Installation
composer require joetjen/cooper
Requires PHP 8.2+ with mbstring.
Development
composer install
composer run precommit # PHPStan (level 8) + PHPUnit
The Cooper Conformance Suite
The Cooper Conformance Suite (libs_prx/cooper_conformance in the
Praxis repository) runs every CASC implementation through an adapter
command, COMMAND ARGS... REQUEST ANSWER. This port's is
bin/cooper-conformance, run from the repository root:
php bin/cooper-conformance request.json answer.json
It reads {"cases": [{"id": ..., "operation": "load", "document": "/abs/path.casc"}]} and writes one result per case, in order: the
loaded value in the suite's typed JSON (what Praxis notation has no
literal for is a tuple tagged with a namespaced keyword: casc/duration,
casc/bytes, casc/secret, casc/module, casc/float, casc/ipv4,
casc/ipv6, casc/date, casc/time, casc/local-date-time,
casc/date-time), or the stage of the error that
refused it (lexer, parser, action, import, merge, loop,
resolve, dotenv). It exits non-zero only when the request cannot be
read or the answer cannot be written. The work, and the load options
every case runs with, are in JOetjen\Cooper\ConformanceAdapter, the
twin of the Elixir reference's Cooper.Conformance. The command is a
development tool and is not listed under composer.json's bin.
Behaviour is checked across implementations by the suite, not by a corpus in this repository: a behaviour change needs a case there.
See CONTRIBUTING.md for how to propose changes.
License
Apache-2.0 -- see LICENSE.