joelwurtz / json-stream-polyfill
Pure-PHP polyfill for the json_stream extension
Package info
github.com/joelwurtz/php-json-stream-polyfill
pkg:composer/joelwurtz/json-stream-polyfill
Requires
- php: >=8.1
- ext-json: *
Provides
This package is auto-updated.
Last update: 2026-07-24 12:58:24 UTC
README
A userland implementation of the json_stream extension's observable API:
json_stream_decode(), JsonStream\Document and the JSON_STREAM_TRANSIENT
constant. It only activates when the extension is not loaded.
Requires PHP >= 8.1 and ext-json (always compiled in).
Loading
Manual:
require '/path/to/polyfill/bootstrap.php';
Composer:
composer require joelwurtz/json-stream-polyfill
The package's Composer autoloader includes bootstrap.php automatically.
It defines everything only when !extension_loaded('json_stream') (and the
class is not already defined), so it is safe to load unconditionally: with
the extension present the polyfill is inert.
What it implements
json_stream_decode(string|resource|array|Traversable $source, int $options = 0): JsonStream\Documentwith the extension's exactTypeErrorwording for invalid sources. Nothing is read from the source until the first access on the Document.final class JsonStream\Document implements IteratorAggregate, ArrayAccess, CountablewithoffsetExists/offsetGet/offsetSet/offsetUnset/count/toValue/getIterator, plus__debugInfo()mirroring the extension'sget_debug_info(kind/scannedEntries/fullyScanned, orkind/valuefor scalar roots), and throwing__clone/__serialize.- Three-state lazy slots (unread → buffered byte span → parsed value): only the accessed path is ever parsed; scans early-exit as soon as the requested key is found; sibling values passed over are recorded as cheap spans.
- Structural scanning is done in PHP (
strcspn/strpos/strspnover a pulled buffer, with simdjson-style global escaped-quote semantics matching the extension's stage-1 indexer). Every scalar token (string, number, literal) andtoValue()materialization is delegated tojson_decode(), so unescaping, UTF-8 validation (JSON_INVALID_UTF8_IGNORE/JSON_INVALID_UTF8_SUBSTITUTE), number semantics (JSON_BIGINT_AS_STRING) andJsonExceptioncodes/messages match ext/json byte-for-byte. One exception:JSON_ERROR_STATE_MISMATCHfromjson_decodeis remapped toSyntax error(code 4), because the extension diagnoses mismatched bracket kinds as a plain syntax error. - Error semantics: syntax errors are only discovered when an access forces
scanning past them; duplicate keys resolve like the extension (early-exit
scans stop at the first occurrence; full scans overwrite in place);
trailing-garbage-after-root detection; depth limit 512 on materialization;
the extension's exact
Errormessages for read-only writes, scalar-root access/count/iteration, transient forward-only violations and released content. JSON_STREAM_TRANSIENT(1 << 30):foreachon a virgin container is a forward-only cursor that records no slots and actually releases consumed buffer prefixes (with the same 64 KiB hysteresis and one-entry lag as the extension), so memory stays bounded over arbitrarily large streams. Anything still referencing a region about to be released (buffered earlier fields, kept child Documents) is force-parsed first, exactly likejs_state_force_resident_before. Once cursor iteration starts, dimension access /count()/toValue()/ re-iteration throw the extension'sErrors.
Parity is enforced by scripts/polyfill_tests.php, which runs the
extension's phpt suite against the polyfill and the differential fuzzer
(scripts/differential.php) through it.
Known divergences (irreducible in userland)
isset()vsoffsetExists()split. The C extension distinguishes the enginehas_dimensionhandler (used byisset()/empty()/??: anullvalue is not set) from theoffsetExists()method (array_key_existssemantics:truefornull, answered structurally without parsing the value). UserlandArrayAccessroutes both through theoffsetExists()method, so the split cannot be reproduced. The polyfill implements the isset semantics (the value is parsed;nullreportsfalse), which is whatisset(),??,empty()and the differential fuzzer observe. Consequences:$doc->offsetExists('k')returnsfalsefor anullvalue where the extension returnstrue, and it can throw aJsonExceptionfor an invalid value where the extension (purely structural) returnstrue. This is whytests/017-offset-exists.phptis skip-listed by the runner.- Warning location for missing keys.
$doc['missing']raisesWarning: Undefined array key "missing"like the extension, buttrigger_error()reports the polyfill's file/line instead of the caller's. - Foreach by reference.
foreach ($doc as &$v)fails (as in the extension) but with the engine's generator message instead ofCannot iterate JsonStream\Document by reference. Similarly, re-using one$doc->getIterator()object for two loops fails with the generator rewind exception; eachforeach ($doc)gets a fresh iterator and behaves normally. - Class surface.
var_dump($doc)shows the same fields via__debugInfo(), but the object hash/id and thegetIterator()return class (Generatorinstead ofInternalIterator) differ. Dynamic properties raise the engine's deprecation instead of the extension's hardError. Internal helper classes exist underJsonStream\Polyfill\.
Performance
Far below the extension (the extension's SIMD stage-1 indexes ~1 GiB/s;
this is interpreted PHP). But the memory model is preserved: laziness
(only the accessed prefix of a stream is pulled and only the accessed path
is parsed) and transient constant-memory iteration both hold, and
toValue() on a fully buffered document is a single json_decode call,
i.e. near-native.