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

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.

JSON 154 bytes
{
  "enabled": true,
  "fallback": null,
  "greeting": "hello world",
  "limits": {
    "cpu": "500m",
    "memory": "256Mi"
  },
  "name": "web-server",
  "replicas": 3,
  "tags": [
    "prod",
    "edge"
  ]
}
RON 121 bytes · 21% smaller
enabled true
fallback null
greeting 'hello world'
limits {
  cpu 500m
  memory 256Mi
}
name web-server
replicas 3
tags [prod edge]

What you get

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.

RON
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}
VocabularyTags
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.

MethodDoes
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.

Conversionphp-ronron-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