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.

Maintainers

Package info

github.com/nylo-core/laravel-deja-json

pkg:composer/nylo/dejajson

Transparency log

Statistics

Installs: 50

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-07-16 07:00 UTC

This package is auto-updated.

Last update: 2026-07-20 17:14:24 UTC


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-Json in allowed_headers. Browsers never send it, so they always get plain JSON.
  • Caches/CDNs: encoded responses carry Vary: X-Deja-Json automatically.
  • 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