Search by

joetjen / cooper

joetjen

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.

v0.2.1 2026-10-21 04:55 UTC

This package is auto-updated.

Last update: 2026-10-27 15:55:08 UTC


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.

CI Packagist Docs

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. .env files are read for you.
  • Imports and layering. Split a document into files, import a glob of them, and override with explicit sigils: +tags appends, -tags removes, ~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 $stage keeps 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 int when they fit in 64 bits, and a CooperInteger (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). The modules option maps a name exactly as written to whatever !module should 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\")" is x-Foo.Bar, not x-Foo\Bar; a mapped one reads as the value it maps to.
  • .env support parses with vlucas/phpdotenv, a dependency of Cooper's (the Elixir original uses dotenvy). The .env.<env> file is named by COOPER_ENV, as in every Cooper: .env.dev, .env.staging, .env.test, .env.prod -- so Laravel's APP_ENV=production reads .env.prod, never .env.production. The files are read from dotenvDir, 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 .env loader. Called explicitly -- never by a load, never by default -- it writes the names the .env files define, and COOPER_ENV, into $_ENV, $_SERVER and putenv(), with the value ${NAME} resolves to, so code reading the process environment directly (Laravel's env(), Symfony's APP_ENV) sees them too. A variable the real environment already has is never overwritten, unless dotenvOverride is set; it returns what it wrote, and calling it twice changes nothing more.
  • ${COOPER_ENV} is always set (CASC.md §7.2): a real COOPER_ENV wins; unset or empty, it falls back to APP_ENV -- read from the same layers as every other name, so a .env file 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's APP_ENV values work as they are: development and local become dev, testing becomes test, production becomes prod, and anything else (staging, qa, ...) is passed through. A real COOPER_ENV is never mapped. The Elixir original falls back to MIX_ENV, then the live Mix.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 where Cache cannot see it -- a compiled PHP file, which shared-nothing PHP wants, is the reason it exists.
  • The envValue load option has no Elixir counterpart: it lets a host decide what a ${NAME} written as a value resolves to (see EnvReference) -- for a framework that compiles configuration into a cache and must keep the environment a runtime placeholder (joetjen/cooper-symfony turns it into Symfony's %env(NAME)%). PHP's frameworks compile their configuration; the BEAM's read it at boot.
  • LiteralTag has no Elixir counterpart: a tag registered as new 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 to Cache::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 failed import.
  • 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.path followed by another complete statement is a delete (the rule CASC.md §5.7 now states), and +info = [...] appends to info.
  • An interpolated key ("region-@{name}" = ..., §4.2) resolves outside for loops, 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 resolve error; 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} substitutes 1; ${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 @list is declared, and +/- in a for ... from body edit the template's copy (§5.5, §8.4); +key = nil appends nil, -key = nil removes it, and removal is strict (1 is not 1.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 #@version header.

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.