JSON's value model,
lighter syntax.
php-ron is a performance-focused PHP implementation of RON (Readable Object Notation). RON keeps JSON's value model but drops avoidable syntax: elided top-level braces, bare strings, optional commas, and repeated-quote strings. It converts losslessly to and from JSON, so you can adopt it only where a person or an LLM reads or writes the data.
$ composer require mbolli/php-ron
- PHP ≥ 8.3
- MIT licensed
- 309 tests passing
- PHPStan level 9
The same data, less ceremony
Every RON document maps 1:1 to a JSON value. Here is one config object in both. RON drops the root braces, the key and string quotes, the colons and the commas. Every quote and brace you save is a real byte, and for an LLM, a real token.
{ "enabled": true, "fallback": null, "greeting": "hello world", "limits": { "cpu": "500m", "memory": "256Mi" }, "name": "web-server", "replicas": 3, "tags": [ "prod", "edge" ] }
enabled true fallback null greeting 'hello world' limits { cpu 500m memory 256Mi } name web-server replicas 3 tags [prod edge]
What you get
-
Lossless round-trip
Any RON document maps 1:1 to a JSON value and back. Author or display RON, keep storing and transmitting JSON, without changing your data model.
-
Reference parity
A behavioral port of the Go reference ron-go, passing the upstream conformance corpus byte for byte.
-
RFC 8785 canonical JSON
Built-in JCS canonicalization with deterministic key order, distinct from the number-text-preserving compact renderer.
-
Canonical SHA-256 hashing
Content-address any value: a SHA-256 of the canonical RON bytes via native
hash('sha256'), no extra dependency. -
Typed vocabularies
Render
{#utc ...}-style typed values compactly and optionally validate their payloads against the official vocabularies (core, time, network, set, math, spatial, color, geo) — or register your own. -
Built for speed
Hot paths scan bytes with native C functions rather than per-character PHP loops, stream RON → JSON without an intermediate tree, and skip the escape-aware scan entirely for sources that cannot need it.
-
One small API
A single static facade with
json_encode-style ergonomics. Invalid input throwsRonException; depth is capped at 512.
Quick start
use Mbolli\Ron\Ron; use Mbolli\Ron\RonMode; // Encode/decode arbitrary PHP values, like json_encode/json_decode Ron::encode(['name' => 'Ada', 'active' => true]); // "name Ada\nactive true" Ron::decode("name Ada\nactive true"); // ['name' => 'Ada', 'active' => true] // One mode per conversion; Pretty is the default, Canonical sorts and // applies the RFC 8785 / I-JSON contract. Ron::toJson("name Ada\nactive true", RonMode::Compact); // {"name":"Ada","active":true} Ron::fromJson('{"name":"Ada","active":true}'); // "name Ada\nactive true" Ron::format($ron, RonMode::Compact); // reformat RON in place // Canonical RON and its SHA-256 hash, and RFC 8785 (JCS) JSON Ron::canonicalRon($json); Ron::canonicalHash($json); Ron::canonicalJson($json);
Typed vocabularies
A typed value is a single-key object whose key starts with #. RON renders it
compactly, and php-ron can validate the payload against the official vocabularies —
core is on by default, the rest are opt-in, and you can register your own.
Unknown tags stay ordinary objects, so documents always round-trip.
account {#uid 4f6e2a91-0c3d-4b7a-9f21-1a2b3c4d5e6f} pattern {#rx [^foo\\d+$ i]} created {#utc 2026-06-13T00:00:00Z} ttl {#dur PT1H30M} host {#ip4 192.0.2.1} balance {#dec '1234.56'} score {#f3v [1.5 2.5 3.5]} roles {#set [admin reader writer]} accent {#clr [oklch 0.7 0.15 230]} parent {# 300}
| Vocabulary | Tags |
|---|---|
core | #uid #url #rx #dec #b64 #sha256 # #tag |
time | #utc #dur |
network | #ip4 #ip6 #cdr |
set | #set #bits |
math | #i64 #u64 #f64 #ivN #vN #iv2…4 #f2v…4v #qat #eul #m2x #m3x #m4x |
spatial | #sph #cyl #bx2 #bx3 #spr #pln #ray #ln2 #ln3 #tri #fru #sh3 #vox |
color | #clr |
geo | #geo #topo |
Custom vocabularies
Register your own namespaced vocabulary — any payload shape, any
rule — and php-ron validates and renders its tags like a built-in. A validator returns
true to validate (accept), false to reject, or any
other value to transform the payload, like the built-in #vox. The
first example shows the full setup; the rest are tag → validator entries for the
same register() call. Expand any:
#com.acme/waypoint — object payload {lat, lng, label}, full setupvalidate
use Mbolli\Ron\Value\RonNumber; use Mbolli\Ron\Value\RonObject; use Mbolli\Ron\Vocabulary\VocabularyRegistry; $registry = VocabularyRegistry::official(); // A validator returns true to accept, false to reject, or a value to transform. // #com.acme/waypoint carries an object payload {lat, lng, label} with range checks. $registry->register('https://acme.example/vocab/geo/v1', [ '#com.acme/waypoint' => function (mixed $payload): bool { if (!$payload instanceof RonObject) { return false; } $field = []; foreach ($payload->members() as [$key, $value]) { $field[$key] = $value; } $lat = $field['lat'] ?? null; $lng = $field['lng'] ?? null; return $lat instanceof RonNumber && abs((float) $lat->text) <= 90 && $lng instanceof RonNumber && abs((float) $lng->text) <= 180; }, ]); $json = '{"office":{"#com.acme/waypoint":{"lat":47.3769,"lng":8.5417,"label":"HQ"}}}'; Ron::fromJson($json, vocabularies: ['https://acme.example/vocab/geo/v1'], registry: $registry); // office {#com.acme/waypoint {label HQ lat 47.3769 lng 8.5417}}
#com.acme/money — money as [currency, amount]validate
// money as [currency, amount] -- returns true to accept, false to reject '#com.acme/money' => fn (mixed $p): bool => is_array($p) && count($p) === 2 && is_string($p[0]) && preg_match('/^[A-Z]{3}$/', $p[0]) === 1 && is_string($p[1]) && preg_match('/^-?\d+(\.\d+)?$/', $p[1]) === 1, // {"price":{"#com.acme/money":["EUR","19.99"]}} -> price {#com.acme/money [EUR '19.99']}
#org.semver/version — a SemVer 2.0.0 stringvalidate
// a SemVer 2.0.0 version string '#org.semver/version' => fn (mixed $p): bool => is_string($p) && preg_match('/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/', $p) === 1, // {"ron":{"#org.semver/version":"1.4.0-beta.2"}} -> ron {#org.semver/version 1.4.0-beta.2}
#com.acme/measure — a measurement as [value, unit]validate
// a measurement as [value, unit] '#com.acme/measure' => fn (mixed $p): bool => is_array($p) && count($p) === 2 && $p[0] instanceof RonNumber && in_array($p[1], ['mm', 'cm', 'm', 'km', 'g', 'kg', 's', 'ms'], true), // {"height":{"#com.acme/measure":[1.83,"m"]}} -> height {#com.acme/measure [1.83 m]}
#com.acme/matrix — the three return modes: false / true / a reshaped payloadtransform
// The three return modes: false rejects, true accepts as-is, any other value transforms. '#com.acme/matrix' => function (mixed $payload): mixed { if (!is_array($payload)) { return false; // reject } if ($payload === []) { return true; // accept the payload unchanged } return new \Mbolli\Ron\Value\MultilineList($payload); // transform: one row per line }, // {"grid":{"#com.acme/matrix":[]}} -> grid {#com.acme/matrix []} // {"id":{"#com.acme/matrix":[[1,0,0],[0,1,0],[0,0,1]]}} -> // id {#com.acme/matrix [ // [1 0 0] // [0 1 0] // [0 0 1] // ]}
#com.acme/flag — transform to a literal true/false with replace()transform
// To transform a payload INTO a bool, wrap it in replace() -- a bare true/false // return would be read as accept/reject instead of as the new payload. '#com.acme/flag' => fn (mixed $p): mixed => is_string($p) ? VocabularyRegistry::replace(in_array($p, ['on', 'yes', 'true'], true)) : false, // {"beta":{"#com.acme/flag":"on"}} -> beta {#com.acme/flag true} // {"beta":{"#com.acme/flag":"off"}} -> beta {#com.acme/flag false}
Official URIs are https://ron.dev/vocab/<name>/v1. Custom
vocabularies use a reverse-DNS namespace, e.g. #com.example/money.
API reference
Everything lives on the static facade Mbolli\Ron\Ron.
| Method | Does |
|---|---|
toJson(string $ron, RonMode $mode = RonMode::Pretty) | RON to JSON. Pretty and Compact keep source order; Canonical is RFC 8785 JSON. |
fromJson(string $json, RonMode $mode = RonMode::Pretty, ?callable $mapper = null, array $vocabularies = [VocabularyRegistry::CORE_V1], ?VocabularyRegistry $registry = null) | JSON to RON; optional typed-value render hook and typed-vocabulary validation (core enabled by default). |
format(string $ron, RonMode $mode = RonMode::Pretty) | Re-render RON source in another mode (RON to RON). |
encode(mixed $value, RonMode $mode = RonMode::Pretty) | Encode any PHP value as RON, like json_encode. |
decode(string $ron, bool $associative = true) | Decode RON to a PHP value, like json_decode. |
canonicalRon(string $json) | Canonical RON: compact bytes for an RFC 8785 / I-JSON value. |
canonicalHash(string $json) | SHA-256 of the canonical RON, 64 lowercase hex digits. |
canonicalJson(string $json) | RFC 8785 (JCS) canonical JSON. |
validate(string $json, array $vocabularies = [VocabularyRegistry::CORE_V1], ?VocabularyRegistry $registry = null) | Validate typed payloads against the enabled vocabularies; throws on invalid. |
tokenize(string $ron) | Lenient, role-aware token stream over RON source (powers this page's highlighting). |
Every method also accepts int $maxDepth = 512. Invalid input throws Mbolli\Ron\RonException.
Performance
Measured throughput (OPcache + JIT), flat and linear from ~1.5 KB to ~320 KB, on the same input against the compiled Go reference. A 1–10 KB payload converts in well under a millisecond.
| Conversion | php-ron | ron-go (Go) | vs ron-go |
|---|---|---|---|
| JSON → RON (compact) | ~29 MB/s | ~12 MB/s | ~2.4× faster |
| JSON → RON (pretty) | ~26 MB/s | ~12 MB/s | ~2.2× faster |
| RON → JSON (compact) | ~19 MB/s | ~147 MB/s | ~7.6× slower |
| canonical hash | ~13 MB/s | ~5 MB/s | ~2.5× faster |
php-ron is ahead of the Go reference on JSON → RON and on the canonical hash
(ron-go decodes JSON through Go's encoding/json; php-ron uses a hand-rolled
scanner and canonicalizes in a single parse). On RON → JSON, ron-go streams in
near-zero-allocation compiled code: the realistic interpreter tax for that direction. Numbers
are from one ~31 KB document on one machine (ron-go v0.1.1); run
composer benchmark to reproduce.
Reach for it where humans and LLMs meet your data
- Config files
- Test fixtures
- Logs
- LLM prompt & context payloads