// Before
$payload = json_encode($response);
$data = json_decode($input, true);
$ok = json_validate($input);
// After
$payload = fastjson_encode($response);
$data = fastjson_decode($input, true);
$ok = fastjson_validate($input);
That's the migration. Search-and-replace json_* for fastjson_*. JSON flags, error constants, and last-error semantics carry across byte-for-byte. The two extensions sit next to each other in the same process; adoption is per call site, not per repo.
On the simdjson_php canonical 14.8 MB corpus, that swap buys 6.06× encode throughput, 2.66× decode, and 5.10× validate against ext/json on the same PHP 8.6.0-dev build. The repo is at github.com/iliaal/fastjson. 0.3.0 shipped yesterday.
Why drop in faster JSON for PHP
ext/json is fine. It's correct, well-maintained, and tracks the spec. On low-traffic endpoints it isn't on anyone's profiler. The cost shows up at scale: any application that calls json_encode and json_decode on every request path eventually finds JSON serialization sitting at the top of a flame graph. API gateways feel it first, then log processors and microservice fan-out paths.
Before fastjson the practical options were two:
- Stay on
ext/json. Eat the CPU cost. - Reach for simdjson_php. It's fast but decode-only and not API-compatible; every call site has to be rewritten around its result shape.
fastjson is option three. It's a native PHP extension that wraps yyjson 0.12.0 (MIT, ~6K LOC of focused C) behind a namespaced API that mirrors ext/json's contract. PHP 8.3 minimum; 8.4 and 8.5 supported; coexists with ext/json.
What "drop-in" actually means here
The risk with any "drop-in" claim is that it covers 90% of cases and silently changes behavior on the 10% that matter. So this section is what fastjson does and what it doesn't:
-
Function signatures track
ext/json.fastjson_encode($value, int $flags = 0, int $depth = 512). Same positional shape. Same defaults. Same return values on success,falseon failure. -
JSON_*flags match byte-for-byte.JSON_UNESCAPED_SLASHES,JSON_UNESCAPED_UNICODE,JSON_PRETTY_PRINT, theJSON_HEX_*family,JSON_THROW_ON_ERROR,JSON_INVALID_UTF8_IGNORE,JSON_INVALID_UTF8_SUBSTITUTE. The integer constants are intentionally identical so user code can pass the same flag value into either function. -
JSON_ERROR_*codes match byte-for-byte, messages don't.fastjson_last_error()returns the sameJSON_ERROR_*int asjson_last_error()for the same failure class, so code branching on error codes works without modification.fastjson_last_error_msg()returns yyjson's parser message (e.g.,"unexpected character"), not ext/json's. Application code that pattern-matches on the message string needs updating; code that branches on the code does not. -
Coexistence, not replacement. Both extensions load. Migrate the call sites where JSON is on the hot path; leave the rest on
ext/json. -
53 phpt tests rewritten from
php-src/ext/json/tests/*.phptrun alongside fastjson's own suite. The rest of the upstream suite is categorized intests/upstream-json/.skiplistwith the reason each test was deferred (most are tests ofext/jsoninternals that don't translate, a few hit known divergences). -
Documented divergences. Large/scientific doubles emit
100000000000000000.0whereext/jsonemits1.0e+17in some ranges. U+2028 / U+2029 line separators emit as ordinary code points;ext/jsonalways escapes them for JSONP safety. Both divergences are in the skiplist with rationale.
The numbers
Full simdjson_php canonical corpus: 14.8 MB across 15 files, the same set the simdjson PHP binding has been benchmarked against for years. Hardware: i9-13950HX. PHP 8.6.0-dev, release build (-O2). fastjson built -O2 against the same PHP. yyjson 0.12.0 with three local patches. Numbers in throughput, MB/sec:
| Operation | fastjson | ext/json | Speedup |
|---|---|---|---|
| Decode (stdClass) | 602 MB/s | 227 MB/s | 2.66× |
| Decode (assoc array) | 628 MB/s | 237 MB/s | 2.65× |
| Encode | 1,092 MB/s | 180 MB/s | 6.06× |
| Validate | 1,352 MB/s | 265 MB/s | 5.10× |
Visual side-by-side, including ext/json with Nora Dossche's open SIMD encode work (php-src#17734) and simdjson_php on the same PHP 8.6.0-dev build, lives at https://iliaal.github.io/fastjson/baseline.html. Reproduce locally with bench/run.php against any PHP install.
The encode speedup is the largest gap because the PHP-native encoder has the most room to give back. ndossche's php-src#17734 patch closes a meaningful chunk of that gap inside ext/json itself using SIMD on string encoding. fastjson and that PR attack the same problem from different angles, and an application can benefit from both once #17734 lands upstream (fastjson re-baselines automatically; the visual page already shows both).
How the encoder gets to 6×
The encoder is one-stage. A zval walks straight into a smart_str buffer via yyjson's writer primitives. There's no intermediate DOM, no two-pass build-then-serialize, no temporary string allocations for the common-case scalars. Each PHP value type maps to one or two yyjson calls; arrays and objects walk recursively into the same path.
A few less-obvious pieces:
-
Custom allocator wired through Zend. yyjson's allocator hooks route every alloc/realloc/free through PHP's
emallocfamily. JSON workloads count againstmemory_limitand against per-request memory accounting; oversized inputs bail out the same way any other PHP allocation does. -
Direct
smart_strintegration. NoRETURN_STRING(estrdup(buf))after a separate allocation. The yyjson writer writes into thesmart_str.sbacking store, which becomes the return zend_string directly. One allocation per encode call in the common case. -
HEX_*flag scan-first. The flagsJSON_HEX_TAG/HEX_AMP/HEX_APOS/HEX_QUOTrewrite specific characters into hex escapes. fastjson scans the string once for any candidate character; if none are present, the rewrite path is skipped entirely and the string is encoded directly. Defensive callers that passHEX_*flags as a precaution on payloads that don't actually contain the substituted characters don't pay the rewrite cost. -
Integer-valued-double shortcut. When a
doubleround-trips losslessly throughint64, fastjson emits it as an integer without going throughphp_gcvtor yyjson's REAL writer. A cheap range check fires beforefloor(), so number-heavy arrays of non-integer doubles don't pay libm per element.
The decode path's gain has different sources: yyjson itself, which parses with less branching and tighter memory locality than ext/json's parser, and a local yyjson patch (YYJSON_READ_VALIDATE_ONLY) that turns the read path into a fast validate-only mode without materializing values.
The memory tradeoff
Worth surfacing before anyone hits it in production. Decode and validate hold the yyjson document object in memory alongside the PHP-side result, because yyjson's value graph is built first, then traversed to produce zvals. Peak heap on decode is roughly 1.7× what ext/json peaks at on the same input. Encode is one-stage and peaks at ~1.06× of ext/json.
Validate is the loudest: peak heap is ~101× ext/json's streaming validator (which sits at a constant ~80 bytes since it doesn't materialize anything). The headline number sounds extreme, but the absolute footprint is bounded by yyjson's read path, and it's already 2.7× lower than yyjson's stock read path thanks to a vendored validate-only patch (YYJSON_READ_VALIDATE_ONLY) that skips the value-graph build.
For most callers, the speedup wins. If the application is validate-heavy on giant inputs under tight memory_limit, the memory profile is a real consideration. The right move there is to leave validate-on-huge-inputs on ext/json and migrate the encode and decode paths. That's exactly what coexistence buys.
Compat harness
53 rewritten phpt tests from php-src/ext/json/tests/*.phpt run alongside fastjson's native suite. They cover the common decode/encode/validate paths, the flag combinations, and the documented error conditions.
The two remaining intentional divergences:
-
Large/scientific doubles. Outside the integer-valued-double shortcut range, fastjson emits yyjson's real-number format.
ext/jsonusesphp_gcvtand switches to scientific notation earlier. The disagreement window narrowed in 0.3.0; what's left is the genuinely-fractional case where yyjson andphp_gcvtproduce different decimal representations of the same IEEE 754 double. -
U+2028 / U+2029 line separators.
ext/jsonalways escapes these for JSONP safety. yyjson treats them as ordinary code points. fastjson follows yyjson's behavior. If JSONP is in the deployment path, setJSON_UNESCAPED_UNICODEoff inext/jsonand stay onext/jsonfor that endpoint, or wrap fastjson output through a JSONP-safe post-step.
Upstream collaboration with yyjson
fastjson vendors yyjson 0.12.0 with three local patches. Full details are in vendor/yyjson/PATCHES.md; the short version of each, and what happened when each was proposed upstream:
-
Lowercase hex digits in
\uXXXXescape table. yyjson emits uppercase;ext/jsonemits lowercase. RFC 8259 §7 allows either, but byte-parity withext/jsonis the project's compat goal. Proposed as yyjson#264 (YYJSON_WRITE_LOWERCASE_HEXflag) and accepted upstream on 2026-05-11. fastjson will drop this patch once the vendored sources advance past yyjson 0.12.0. -
YYJSON_READ_VALIDATE_ONLY, no-tree validate mode. Forks yyjson's parser entry point to skip the value-graph build entirely; peak memory drops 2.7× on the validate corpus. Not yet proposed upstream; the API surface needs a round of review before it's ready to submit. -
Public
yyjson_write_string_to_buf()wrapper. Exposes yyjson's internal direct-write primitive so fastjson's one-stage encoder can compose at the buffer level. Proposed as yyjson#266 and closed upstream; the maintainer preferred to keep that surface private. The wrapper stays vendored locally; fastjson lives with it indefinitely.
Install
pie install iliaal/fastjson
Or build from source:
phpize
./configure
make -j
make test
sudo make install
PHP 8.3 minimum. No external library dependencies; yyjson is vendored in src/yyjson/.
The four-character migration
fastjson stays useful as long as yyjson's design choices (one-stage encoder, validate-only fast path, allocator hooks) beat what fits into ext/json's compatibility envelope. For the call sites where JSON serialization shows up on a flame graph, the migration is four characters; the rest of the codebase doesn't have to care.
Repo: https://github.com/iliaal/fastjson. Benchmark methodology and reproduction: bench/run.php. Visual baseline: https://iliaal.github.io/fastjson/baseline.html.
Top comments (0)