nylo / dejajson
Give zlib déjà vu: compress Laravel JSON responses against a dictionary trained on your own API, cutting payloads far below what gzip can reach. Adds response()->dejaJson() and a drop-in middleware; pairs with the dejajson Dio interceptor for Flutter.
Requires
- php: ^8.1
- ext-json: *
- ext-zlib: *
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
README
Give zlib déjà vu.
Your API keeps sending the same shapes — the same keys, the same enum values, the same URL prefixes and timestamp formats. Generic compression (gzip, brotli, SmallJson's deflate mode) rediscovers that from scratch on every response, which is why it does so little for the small payloads real apps send most. DejaJson trains a shared compression dictionary on samples of your own responses; from then on the compressor has effectively already seen each response before it starts, and only the genuinely new bytes cost anything.
Measured on realistic Laravel API payloads (php bin/benchmark.php):
| Payload | plain JSON | transport gzip | dejajson d |
|---|---|---|---|
| single resource | 693 B | 437 B (−37%) | 58 B (−92%) |
| list of 25 | 17,469 B | 1,581 B (−91%) | 887 B (−95%) |
| list of 200 | 137,919 B | 8,002 B (−94%) | 7,303 B (−95%) |
The same idea is being standardised for the web as RFC 9842 (Compression
Dictionary Transport); DejaJson applies it to APIs you control both ends of,
over a simple negotiation header. Pairs with the
dejajson Dart package: a
drop-in Dio interceptor restores plain JSON before your Flutter app sees it.
- Zero controller changes — a middleware re-encodes any
JsonResponse(resources, paginators, 422 validation errors included) for clients that ask for it; everyone else gets plain JSON. - Safe by negotiation — clients advertise
X-Deja-Json: dz; dict=<id>; the dictionary is only used when both sides hold identical bytes, verified by id and enforced again by zlib's own dictionary checksum. A stale client falls back to dictionary-less zlib, never to garbage. - No new server dependencies — everything is stock
ext-zlib.
Getting started
composer require nylo/dejajson
Register the middleware on your API routes (or globally in the api group):
Route::middleware('dejajson')->group(function () { // every JsonResponse in here is DejaJson-encoded for advertising clients });
That's already worth ~gzip. The big win needs a dictionary:
# 1. Dump a handful of real response bodies (a few per endpoint shape) curl -s https://your-api.test/api/users > storage/dejajson-samples/users.json curl -s https://your-api.test/api/users/1 > storage/dejajson-samples/user.json curl -s https://your-api.test/api/posts > storage/dejajson-samples/posts.json # 2. Train php artisan dejajson:train storage/dejajson-samples
The command writes storage/app/dejajson/dictionary.bin (override with
DEJAJSON_DICTIONARY or --out) and prints its id. Ship that exact file
inside your app — for Flutter, copy it into the project's assets and hand it
to the interceptor (see the Dart package README). Clients advertise the id
automatically; the server uses dictionary mode whenever the ids match.
Retraining later is safe: a client still holding the old dictionary just
falls back to mode z until its next app update.
The macro
For hand-picked endpoints instead of (or as well as) the middleware:
return response()->dejaJson($users); // mirrors response()->json()
Options
php artisan vendor:publish --tag=dejajson-config
| Key | Default | |
|---|---|---|
enabled |
true |
Master switch — off means plain response()->json() behaviour. |
modes |
'dz' |
What this server will produce; d engages only once a dictionary is trained. |
negotiate |
true |
Only encode for clients sending the request header. |
request_header |
X-Deja-Json |
Must match the interceptor's requestHeader. |
dictionary.path |
storage/app/dejajson/dictionary.bin |
Where the trained dictionary lives. |
min_bytes |
0 |
Skip payloads smaller than this outright. |
only_when_smaller |
true |
If encoding wouldn't shrink the body, send plain JSON. |
level |
6 |
zlib level 1–9. |
stats_header |
false |
Adds X-Deja-Json-Stats: mode=d; dict=…; plain=…; sent=…; saved=…%. |
Manual use
The codec is framework-free — queues, websockets, cache blobs:
use DejaJson\Facades\DejaJson; $binary = DejaJson::encode($data); // envelope using the server dictionary $data = DejaJson::decode($binary, true); // assoc arrays back out
Wire format (v1)
byte 0–1 magic "DJ"
byte 2 format version (0x01)
byte 3 mode — "d" (zlib + shared dictionary) or "z" (zlib)
byte 4+ zlib stream (RFC 1950) of the minified UTF-8 JSON document
Sent with content type application/vnd.dejajson. zlib framing is
deliberate: the stream embeds an Adler-32 of the dictionary (so a wrong
dictionary fails loudly) and of the content (so corruption does too) — and
Dart's ZLibCodec only honours preset dictionaries on zlib-framed streams.
Notes
- CORS: if browsers call the API too, whitelist
X-Deja-Jsoninallowed_headers. Browsers never send it, so they always get plain JSON. - Caches/CDNs: encoded responses carry
Vary: X-Deja-Jsonautomatically. - Alongside
nylo/smalljson: different header and content type — both can be installed while you migrate; a client advertising both gets whichever middleware wraps the route. - Compression side channels: as with any compressed response (transport gzip included), avoid mixing attacker-controlled input and secrets in one payload if your threat model includes BREACH-style size probes. The dictionary itself is static per deploy and adds no new oracle.
- Timing: dictionary compression of typical API payloads is well under a millisecond at level 6.
Testing
composer test # phpunit (unit + feature) composer bench # size/fidelity benchmark composer vectors # regenerate cross-implementation vectors
License
MIT © Anthony Gordon