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 with no escapes. 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.1
- MIT licensed
- 258 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, math, spatial, color, geo) — or register your own. -
Built for speed
Hot paths scan bytes with native C functions, sort keys with
array_multisort, and stream RON → JSON without an intermediate tree. -
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; // Encode/decode arbitrary PHP values, like json_encode/json_decode Ron::encode(['name' => 'Ada', 'active' => true]); // active true\nname Ada\n Ron::decode("name Ada\nactive true"); // ['active' => true, 'name' => 'Ada'] // RON -> JSON (compact by default, canonical key order) Ron::toJson("name Ada\nactive true"); // {"active":true,"name":"Ada"} Ron::toJson($ron, pretty: true); // multiline JSON // JSON -> RON (pretty by default, canonical key order) Ron::fromJson('{"name":"Ada","active":true}'); // active true\nname Ada\n Ron::fromJson($json, pretty: false); // compact RON // 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} 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]} location {#lla [-73.9857 40.7484 381]} accent {#clr [oklch 0.7 0.15 230]} parent {# 300}
| Vocabulary | Tags |
|---|---|
core | #uid #url #dec #b64 #sha256 # #tag |
time | #utc #dur |
network | #ip4 #ip6 #cdr |
math | #i64 #u64 #f64 #ivN #vN #iv2…4 #f2v…4v #qat #eul #m2x #m3x #m4x |
spatial | #lla #sph #cyl #bx2 #bx3 #spr #pln #ray #ln2 #ln3 #tri #fru #sh3 #vox |
color | #clr |
geo | #geo |
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, bool $pretty = false, bool $canonical = true) | RON to JSON (compact, canonical key order by default). |
fromJson(string $json, bool $pretty = true, bool $canonical = true, ?callable $mapper = null, array $vocabularies = [VocabularyRegistry::CORE_V1], ?VocabularyRegistry $registry = null) | JSON to RON (pretty by default); optional typed-value render hook and typed-vocabulary validation (core enabled by default). |
encode(mixed $value, bool $pretty = true, bool $canonical = true) | 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) | Compact, canonically-ordered RON (the canonical byte form). |
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) | ~19 MB/s | ~17 MB/s | ~on par |
| JSON → RON (pretty) | ~16 MB/s | ~17 MB/s | ~on par |
| RON → JSON (compact) | ~15 MB/s | ~81 MB/s | ~5.7× slower |
| canonical hash | ~19 MB/s | — | — |
php-ron matches the Go reference on JSON → RON (ron-go decodes JSON through Go's
encoding/json; php-ron uses a hand-rolled scanner). 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; run
composer benchmark to reproduce.
Reach for it where humans and LLMs meet your data
- Config files
- Test fixtures
- Logs
- LLM prompt & context payloads