pcrov/jsonreader
A streaming, pull-based JSON parser for PHP. Process large documents incrementally from strings or streams without loading everything into memory. Iterate over tokens and events with a simple, readable API—ideal for big files and memory-constrained environments.
pcrov/jsonreader is a plain library — there is no service provider, facade, or config to publish. Install it and you're done:
composer require pcrov/jsonreader
Read the package README's usage block first, then the JsonReader class itself; the public surface is small (open/json/read/close, plus name/type/value/depth and the navigation methods skip/next). Skip everything else until you need it.
The mental model is a forward-only cursor over a token stream, not a DOM:
use pcrov\JsonReader\JsonReader;
$reader = new JsonReader();
$reader->open(storage_path('app/export.json')); // or $reader->json($string)
while ($reader->read()) {
printf(
"%s%s => %s\n",
str_repeat(' ', $reader->depth()),
(string) $reader->name(),
var_export($reader->value(), true)
);
}
$reader->close();
First real use case in a Laravel app: an export/import job that currently does json_decode(file_get_contents(...)) on a file too big for memory. Swap the decode for the loop above, filter on $reader->type() and $reader->depth(), and you've cut peak memory from "whole document" to roughly "one token".
1. Streaming the top-level array of records. The most common shape in app code — a giant array of objects:
$reader = new JsonReader();
$reader->open($path);
try {
$reader->read(); // enter the outer [
while ($reader->read() && $reader->depth() === 1) {
if ($reader->type() === JsonReader::OBJECT) {
yield $this->collectObject($reader); // one record at a time
}
}
} finally {
$reader->close();
}
Wrap this in a Generator and a small service class so jobs and commands share it. Always close() in finally — the reader holds an open handle, and queue workers are long-lived.
2. Collect only the fields you need, skip() the rest. skip() jumps past the current container's children; use it to avoid paying for subtrees you'll throw away:
$depth = $reader->depth();
$record = [];
while ($reader->read() && $reader->depth() > $depth) {
if ($reader->depth() === $depth + 1 && !in_array($reader->type(), [JsonReader::OBJECT, JsonReader::ARRAY], true)) {
$record[$reader->name()] = $reader->value();
} elseif ($reader->type() === JsonReader::OBJECT || $reader->type() === JsonReader::ARRAY) {
$reader->skip(); // nested blob we don't care about
}
}
This "flatten to scalars, skip containers" loop is the workhorse pattern; almost every consumer boils down to it.
3. Filtering while scanning. For a "find the one record with id X" task you never build the array at all: read to the record's OBJECT token, inspect fields until you see a mismatch, skip(), move on. Constant memory regardless of file size.
4. Fast-fail validation of uploads. Before storing a user-uploaded JSON file, run a cheap full scan with an empty body — it throws on malformed input and costs one pass. Much better UX than discovering truncation days later in a downstream job.
5. Streams instead of paths. open() takes anything fopen() accepts, so Storage::readStream(), php://stdin, and compress.zlib://…gz all work. Great for php artisan import piping from another process, and it means you rarely need a temp file.
6. Integration tip — don't hide it behind a repository. Because it's a cursor, it composes badly with Eloquent-style lazy collections and doesn't survive serialization. Keep it inside a job/command, hand each record to your normal action/DTO layer, and let the framework handle persistence.
value() is for scalars. On OBJECT/ARRAY tokens don't expect a PHP array — you're standing at the opening brace. Branch on type() and descend.open() twice (or restructure so one pass yields what both consumers need). Calling open()/json() resets the reader mid-stream — useful, but silently discards state.read() past it, i.e. after you may have already imported half a million records. Either wrap the loop in a transaction/savepoint, or pre-validate small-to-medium files with json_decode (which is also faster there).\xEF\xBB\xBF (common in files from Excel/Windows tooling) can blow up the first read(). Strip it when you open the stream, and normalize encoding before parsing.read() just returns false immediately — which looks identical to a successful zero-token parse. Treat "zero tokens read" as a malformed upload in your validation path.DOUBLE and cast deliberately. Note that JsonReader::INT vs JsonReader::DOUBLE is your only signal.name() semantics differ by container. Object members report their key; array elements report an integer index; the root reports null. Don't assume string — var_dump() it once against your fixture before writing string-typed code.depth() is the cheapest router you have. depth() === 1 for records inside a top-level array is a robust guard; hard-coded paths break the moment the export wrapper changes.skip() vs next(). skip() abandons the children of the current container (cursor lands on its end token); next() advances to the next sibling. Mixing them up is the classic source of "I read every other record" bugs.json_decode() wins comfortably for anything under a few MB — and gives you a JsonException with a message you can show a user. Use the pull parser specifically for the big/streamed cases.depth/type/name/value) behind a --verbose flag, or assert token sequences in a feature test against a small fixture. Token streams are trivially snapshot-testable, which is the fastest way to catch a skip() regression.JsonReader is stateful and not safe to share between concurrent consumers; instantiate per stream, close in finally.How can I help you explore Laravel packages today?