joetjen / cooper
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
This package is auto-updated.
Last update: 2026-10-08 18:32:16 UTC
README
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.
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..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 a corpus of conformance cases shared
by every Cooper implementation (tests/conformance, see its
DIVERGENCES.md) 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
See CONTRIBUTING.md for how to propose changes.
License
Apache-2.0 -- see LICENSE.