PHP client for OnlineConf: reads configuration from the CDB
modules that onlineconf-updater (or onlineconf-csi-driver) delivers to the host, with the same value
semantics as the other OnlineConf clients.
- PHP ≥ 8.1,
ext-dbawith thecdbhandler,psr/log. No other dependencies. - Typed getters with defaults (
getInt,getDuration, …) and strict variants (requireInt, …). - Subtrees, child lists,
getTree()/walk(). - Reload on file change with a throttled
stat(), per-process value cache, zero reads for repeated keys. ArraySourcefor tests of your own code,bin/onlineconf-getfor shells and debugging.
The name onlineconf/onlineconf-php on Packagist belongs to an unrelated package by another author
(versions 1.0.x, a different API), so this library is installed from its Git repository. Add the
repository before requiring the package; Composer 2 takes a package from the first repository that
provides it, so the Packagist entry is ignored:
composer config repositories.onlineconf-php vcs https://github.com/onlineconf/onlineconf-php
composer require onlineconf/onlineconf-php:^2.0Versions of this library start at 2.0.0 on purpose: a ^2.0 constraint can never resolve to the
unrelated 1.0.x package, so a forgotten repository entry fails loudly instead of installing the wrong
code.
php -r 'var_dump(dba_handlers());' # must list "cdb"If cdb is missing:
- Debian/Ubuntu:
apt install php8.x-dba(the bundledcdb/cdb_makehandlers are always included). - Official
php:*Docker images:docker-php-ext-install dba. - Images built with
install-php-extensions: adddbato the list explicitly. - Alpine:
apk add php8x-dba.
If the extension is loaded but the handler is absent, opening a module throws
Onlineconf\Exception\OpenException with the message from dba_open() ("No such handler: cdb").
use Onlineconf\Onlineconf;
$tree = Onlineconf::module(); // /usr/local/etc/onlineconf/TREE.cdb
$tree = Onlineconf::module('TREE'); // the same object
$tree = Onlineconf::module('/path/to/custom.cdb');
$host = $tree->getString('/my/service/db/host', 'localhost');
$port = $tree->getInt('/my/service/db/port', 3306);
$enabled = $tree->getBool('/my/service/enabled', false);
$timeout = $tree->getDurationMs('/my/service/http/timeout', 5000); // "1.5s" → 1500
$hosts = $tree->getStrings('/my/service/hosts', []); // "a, b" or ["a","b"]
$opts = $tree->getArray('/my/service/options', []); // JSON value
$svc = $tree->subtree('/my/service');
$svc->getInt('/db/port', 3306); // reads /my/service/db/portOnlineconf::module() is a process-wide registry: the same file (after path normalization and
realpath) always returns the same Module. Settings are read once, on the first call; override them
before that:
Onlineconf::setLogger($psrLogger); // NullLogger by default
Onlineconf::setDefaultDir('/etc/onlineconf');
Onlineconf::setDefaultModule('TREE');
Onlineconf::setCheckInterval(5); // seconds between stat() checks, 0 = every accessFor dependency injection and tests build the module yourself:
use Onlineconf\Module;
use Onlineconf\Source\CdbSource;
$module = new Module(new CdbSource('/usr/local/etc/onlineconf/TREE.cdb'), $logger, checkInterval: 5);Highest priority first:
Onlineconf::setDefaultDir(),Onlineconf::setDefaultModule().- Environment:
ONLINECONF_DIR(directory),ONLINECONF_CONFIG(path of a client config file, read as in 4). - Environment:
CDB_CONFIG_FILE=/usr/local/etc/onlineconf/TREE.cdb— the default module file; gives both the directory and the module name. - Client config
/usr/local/etc/onlineconf.yaml(the fileonlineconf-updateris configured with): keydata_dir. Only flatkey: valuelines are understood; nested structures are logged and ignored.enable_cdb_client: 0is logged as a warning and ignored — the text format is not supported. A missing or unreadable file, or one withoutdata_dir, is skipped with a debug log entry and the next level applies. - Built-in defaults:
/usr/local/etc/onlineconf,TREE.
Empty environment variables count as unset.
A module name without / is a file in the directory; a name with / is a path. .cdb is appended
when the name has no extension (TREE → TREE.cdb, custom.db stays as is and is still read as CDB).
The text .conf files next to the .cdb files are ignored.
examples/onlineconf/ contains ready-made modules: TREE.cdb (a tree with child lists, empty values,
JSON objects and arrays, long values, a node with numeric child names) and legacy.cdb (dot-notation keys
without child lists). Next to
each .cdb lies a .conf with the same content in the updater's text format — a human-readable listing
only, the library never reads it:
bin/onlineconf-get --dir=examples/onlineconf /app/hosts/main
bin/onlineconf-get --dir=examples/onlineconf --json /app/services/gateway/client_settings | jq .
bin/onlineconf-get --dir=examples/onlineconf --tree /app/nginx
bin/onlineconf-get --dir=examples/onlineconf --tree /app/shards # numeric child names → JSON array
bin/onlineconf-get --dir=examples/onlineconf --bool /app/nginx/anti-ddos/enabled && echo on
bin/onlineconf-get --module=examples/onlineconf/legacy.cdb db.host
bin/onlineconf-get --dir=examples/onlineconf --interactiveThe same files work with ONLINECONF_DIR=$PWD/examples/onlineconf and Onlineconf::module().
They are built from the test fixtures by php examples/build.php; a test keeps them in sync.
| Method | Returns | Accepts |
|---|---|---|
getString($path, string $default) |
string |
s as is (UTF-8, no trim) |
getInt($path, int $default) |
int |
s matching ^[+-]?\d+$ |
getFloat($path, float $default) |
float |
s numeric string without surrounding whitespace |
getBool($path, bool $default) |
bool |
s: "" and "0" are false, anything else is true |
getDuration($path, float $default) |
seconds as float |
s duration with units, see below |
getDurationMs($path, int $default) |
milliseconds as int |
same, rounded to the nearest ms |
getStrings($path, array $default) |
list<string> |
s comma-separated (trimmed, empties dropped) or j array of strings |
getArray($path, array $default) |
array |
j object or array, json_decode(..., true) |
get($path, mixed $default) |
mixed |
s → string, j → decoded JSON; no validation |
has($path) |
bool |
key exists (any type) |
requireX($path) / require($path) |
same as getX |
throws instead of returning a default |
subtree($prefix) |
Subtree |
the same methods with a path prefix |
children($path) |
list<string> |
names from the child list <path>/ |
getTree($path, ?int $maxDepth) |
nested arrays | see below |
walk($path, callable $visitor, ?int $maxDepth) |
— | depth-first traversal with raw values |
checkForUpdates() |
bool |
stat now, reload if changed |
version() |
string |
inode:mtime:size of the loaded file |
Values in OnlineConf are stored with a type byte: s (text; numbers and booleans are text too) or j
(JSON; YAML is converted to JSON by the updater). Rules, shared with the other OnlineConf clients:
- Getters with a default never throw (with one exception, below). Missing key, wrong type byte
(
jwhere a string is expected,swhere JSON is expected, an unknown byte such asc) or a string that does not parse ("3O"forgetInt,"1d"forgetDuration) all return the default and log awarning. This is why the typed getters exist:(int) "abc"silently gives0,getIntgives your default and a log line. require*getters throw:NotFoundException,FormatException(wrong type byte or JSON of the wrong shape),ParseException(int/float/duration does not parse). Use them in bootstrap code where a missing key must fail the start.- Invalid JSON is critical. A
jvalue (or a child list) that is not valid JSON throwsInvalidJsonExceptionfrom every method that decodes it — includingget(),getArray(),getStrings(),children()andgetTree()with a default. The updater validates JSON when it writes the file, so broken JSON means a broken delivery pipeline, and a silent default would be worse than a failure. Methods that do not decode the value (has(),getString(),walk()) do not check it. Nothing is cached on error; after the file is replaced the call is retried. - Duration: see the next section.
- Paths are opaque. Getters do not parse them and do not require a leading
/, so legacy modules with dot-notation keys (db.host,lib.graphite.carbon.port) work. Onlysubtree(),children(),getTree()andwalk()know about/.
All exceptions extend Onlineconf\Exception\OnlineconfException; OpenException is thrown when a
module file cannot be opened.
getDuration() returns seconds as float, getDurationMs() milliseconds as int (rounded to the nearest
millisecond). Both use Duration::parse(), which is public and can be reused in your own code. The value
must be an s value in one of two forms:
- A bare number — seconds. Integer or decimal:
"30"→ 30.0,"0.5"→ 0.5,"0"→ 0.0. The form is chosen by a simple rule: a string without any of the lettersh,m,sis a bare number. - A number with units — one or more
<number><unit>groups written without spaces, with an optional leading sign:"300ms","1.5h","2h45m","1h30m10s","-1m","+2s". Each number is an integer or a decimal (1.5h,.5s,1.s), the groups are summed, the sign applies to the whole value.
| Unit | Meaning | Example → seconds |
|---|---|---|
ns |
nanoseconds | "1000000ns" → 0.001 |
us, µs (U+00B5), μs (U+03BC) |
microseconds | "1500us" → 0.0015 |
ms |
milliseconds | "300ms" → 0.3 |
s |
seconds | "1.5s" → 1.5 |
m |
minutes | "5m" → 300.0 |
h |
hours | "2h45m" → 9900.0 |
Anything else is a ParseException (the default is returned and a warning is logged by getDuration()):
an empty string, unknown units ("1d", "1w", "2 hours"), spaces ("1 h", "1.5 h"), a unit without a
number ("s"), a sign without a number, a comma as the decimal separator ("1,5s").
Days and weeks are deliberately not supported: this is the unit set every OnlineConf client understands,
so a value that parses in one service parses in all of them. Write "24h" or "168h" instead.
$svc = $tree->subtree('/my/service'); // prefix is normalized: '/a//b/' → '/a/b'
$svc->getInt('/timeout', 30); // /my/service/timeout
$svc->subtree('/db')->getString('/host'); // /my/service/db/host
$svc->path('/timeout'); // '/my/service/timeout'
$svc->children(''); // children of /my/service itselfGetter paths inside a subtree must start with / and are simply concatenated with the prefix.
children(), getTree() and walk() need child lists: keys <path>/ (for the root: /) whose
value is a JSON array of child names, written by the updater when its child_lists feature is on.
A real TREE.cdb produced by the current updater does contain the root key / (verified 2026-09-07).
When a module has no child lists at all, these methods return empty results and log a warning once.
getTree() returns nested arrays: a leaf becomes its value (s → string, j → decoded), a node with
children becomes an array keyed by child name in child-list order, a node that has both a value and
children keeps its value under the key '', a node with neither is null:
/my/service s1
/my/service/ j["db","timeout"]
/my/service/timeout s30
/my/service/db/ j["host","opts"]
/my/service/db/host sdb.local
/my/service/db/opts j{"pool":5}
$tree->getTree('/my/service');
// ['' => '1', 'db' => ['host' => 'db.local', 'opts' => ['pool' => 5]], 'timeout' => '30']
Child names are PHP array keys, so numeric names become integer keys: $tree['0'] and $tree[0] are the
same element, but foreach yields ints, and a node whose children are named 0..n-1 is a list for
json_encode() (and for onlineconf-get --tree), printed as a JSON array without the names.
examples/onlineconf/TREE.cdb has such a node:
/app/shards/ j["0","1","2"]
/app/shards/0/host sshard-0.example.com
/app/shards/0/weight s2
/app/shards/1/host sshard-1.example.com
...
$tree->children('/app/shards'); // ['0', '1', '2'] — strings, as stored
$tree->getTree('/app/shards'); // [0 => ['host' => 'shard-0.example.com', 'weight' => '2'], 1 => [...], 2 => [...]]
bin/onlineconf-get --dir=examples/onlineconf --tree /app/shards # a JSON array: [{"host": ..., "weight": "2"}, ...]Limitation: opts (a JSON object) and db (a subtree) are both plain arrays in the result; you need to
know the schema of your subtree. When the distinction matters use walk(), which reports the type byte
and raw value of every node; node paths come without a trailing slash, the root as ''. maxDepth limits
the descent (nodes at that depth are reported as leaves).
getTree('/') without a limit reads the whole module — one lookup per node — so use it deliberately.
The updater replaces a module file atomically (write to a temporary file, rename over the old one),
so an open handle keeps reading the old, consistent inode. The client:
- runs
stat()on the path at most once percheckIntervalseconds (default 5;0= every access); - reloads when inode, mtime or size changed: opens the new file, drops the value cache, changes
version(), logsinfo; - keeps the old data and logs
errorwhen the new file cannot be opened or is not a valid CDB (the header is validated because thecdbhandler itself accepts any file); the next check retries.
There are no subscriptions or callbacks — PHP has no background threads, and in PHP-FPM a subscription would live for one request. A long-running worker decides for itself:
while ($job = $queue->next()) {
if ($module->checkForUpdates()) { // stat right now, ignoring the interval
$client = makeClient($module); // rebuild whatever depends on the config
}
// or: compare $module->version() with a remembered value
}Everything read is cached per process: raw bytes per key and decoded values per (key, requested type),
until a reload. Measured with strace on Linux: after the first access to a key, 1000 repeated
getString()/getArray() calls produce zero syscalls, and only one newfstatat appears once the
check interval has passed.
| Runtime | State kept between requests | Cost |
|---|---|---|
| CLI daemon, queue worker, RoadRunner / FrankenPHP worker mode | everything | one stat per interval, reads only on first access and after reload |
| PHP-FPM | nothing | one dba_open (2 KB header, page cache) and one stat per request, then one dba_fetch per unique key |
A request reading ~20 keys spends about 100 µs on configuration. Sharing decoded values between FPM
requests (APCu, shmop) is out of scope; the Source interface with version() lets you add such a
cache as a decorator without touching Module.
Why dba_open and not dba_popen. A persistent handle is bound to the path, not the inode:
after the updater renames a new file into place, the handle keeps reading the old inode until it is
closed. On PHP 8.1–8.3 dba_close() removes the handle from the persistent list and the next
dba_popen() opens the new inode; since PHP 8.4 dba_close() no longer drops the persistent entry and
dba_popen() keeps returning the stale inode (see tests/DbaPersistentHandleTest.php). Independently
of that, userland cannot ask a dba handle which inode it holds, and no userland state survives an FPM
request, so a persistent handle from a previous request cannot be validated — reopening it costs the
same as dba_open. The library therefore uses dba_open once per Module lifetime.
onlineconf-get [--module=TREE|<file>] [--dir=DIR] [--bool] [--json] [--reencode] [--tree] <path>
onlineconf-get [--module=TREE|<file>] [--dir=DIR] [--json] [--reencode] [--tree] --interactive
- default:
svalues are printed as is,jvalues as the stored JSON text; exit 0. - missing key:
No such keyon stderr, exit 1. File, format or invalid-JSON errors: message on stderr, exit 2. Wrong arguments: usage, exit 64. --bool: prints nothing; exit 0 whengetBoolis true, 1 when false, 2 when the key is missing or not a string. Not available with--interactive.--json: any value as JSON (strings become JSON strings) — forjq.--reencode: decodejvalues and print them re-encoded by PHP ({}becomes[], integers beyondPHP_INT_MAXbecome floats) — whatgetArray()gives to the application.--tree:getTree($path)as pretty JSON.--interactive: reads paths from stdin line by line until EOF; errors do not stop the loop.
Installed into vendor/bin by Composer; it uses the library itself, no separate reading logic.
ArraySource is an in-memory source with exactly the same semantics as the CDB one (the library's own
test suite runs the same value tests against both):
use Onlineconf\Module;
use Onlineconf\Source\ArraySource;
$source = ArraySource::fromValues([
'/my/service/db/host' => 'db.local', // string → s value
'/my/service/db/opts' => ['pool' => 5], // array → j value (JSON)
'/my/service/flag' => null, // null → empty s value
'db.host' => 'legacy', // dot-notation keys work too
]); // child lists are generated for "/" paths
$module = new Module($source, checkInterval: 0);
$source->replaceValues(['/my/service/db/host' => 'other']); // next access reloads, version() changesnew ArraySource(['/k' => 'svalue']) takes raw values with the type byte; replace() is its raw
counterpart. Child lists (<path>/ keys) are always generated from the paths and must not be passed;
this holds for OverrideSource::with()/override() too, where they are merged with the inner lists. Any class
implementing Onlineconf\Source can be used the same way, for example a source reading a local file during
development without the updater.
To keep the real module and override only some keys, wrap the source in OverrideSource:
use Onlineconf\Source\OverrideSource;
$source = new OverrideSource(new CdbSource('/usr/local/etc/onlineconf/TREE.cdb'));
$module = new Module($source, checkInterval: 0); // 0: pick up overrides immediately
$source->with(['/my/service/timeout' => '1', '/my/service/feature' => '0'], function () use ($module) {
$module->getInt('/my/service/timeout', 30); // 1 — everything else still comes from the CDB
$module->children('/my/service'); // CDB children plus "feature"
});
// restored here, also when the callback throws
$source->override(['/my/service/timeout' => '1']); // until clear(), e.g. in setUp()/tearDown()
$source->clear();Every change of the overrides changes version() and makes the module drop its cache, so values read
before the override are not served from it.
Point the client at your own directory and build a CDB there:
export ONLINECONF_DIR=$HOME/onlineconf
mkdir -p $ONLINECONF_DIR
php -r '
$db = dba_open(getenv("ONLINECONF_DIR") . "/TREE.cdb", "n", "cdb_make");
dba_insert("/", "j[\"my\"]", $db);
dba_insert("/my/", "j[\"service\"]", $db);
dba_insert("/my/service/", "j[\"db\"]", $db);
dba_insert("/my/service/db/", "j[\"host\"]", $db);
dba_insert("/my/service/db/host", "sdb.local", $db);
dba_close($db);
'
vendor/bin/onlineconf-get /my/service/db/hostOr run onlineconf-updater against a development server. Only .cdb files are read; the text .conf
format is legacy and is not supported.
- Text
.confmodules (enable_cdb_client: 0). - Change subscriptions / callbacks — use
checkForUpdates()andversion(). - CBOR (
c) or any type byte other thansandj: a format error. - Framework integrations (Laravel, Symfony): separate packages built on
ModuleandOnlineconf::module().
composer install
composer check # php-cs-fixer --dry-run, phpstan (level max), phpunit with 100% line coverageWithout a local ext-dba use the development image (PHP CLI + dba + pcov + Composer) through
docker/run.sh, which builds the image on first use and runs the given command as your user:
docker/run.sh composer install
docker/run.sh composer check
docker/run.sh bin/onlineconf-get --dir=examples/onlineconf /app/hosts/main
PHP_VERSION=8.4 docker/run.sh composer check # another PHP version
docker/run.sh # interactive shellRun the tests as a non-root user (the script does): one test makes a file unreadable and is skipped
for root. CI runs the same checks on PHP 8.1–8.5 (.github/workflows/ci.yml). Test fixtures are
generated at run time by a pure-PHP CDB writer (tests/Support/CdbWriter.php) whose output is
byte-identical to cdb_make.
The library was written with Claude (Anthropic) from a detailed specification, under human direction and review. Every line is covered by tests, static analysis and CI; the maintainers are responsible for the code as for any other. Issues and pull requests are welcome.
MIT.