johnstevenson/json-works
A PHP library for reading, editing, and writing JSON while preserving original formatting and whitespace. It provides a simple API for precise changes to JSON documents, making it ideal for configuration files and other human-maintained JSON that should stay readable.
Install
composer require johnstevenson/json-works
Pure PHP, no extensions. Only 26 stars and the last release was 2023-04-17, so treat it as a small, stable, low-maintenance library: pin the version and add your own regression tests around it.
Where to look first
src/Document.php — the entire public surface: fromJson(), fromFile(), toJson(), toFile(), query(), add(), replace(), remove(), validate().Format/formatter classes — this is where indentation, line endings and "what whitespace do we emit for a newly added node" live. Read these before you rely on byte-for-byte round-trips.tests/ — the round-trip tests are the best documentation of the intended fidelity guarantees (trailing newline, empty {}, tabs vs spaces, unicode escapes).First use case: patch composer.json without reformatting it
use JohnStevenson\JsonWorks\Document;
$doc = new Document();
$doc->fromFile(base_path('composer.json'));
$doc->add('/scripts/post-install-cmd', ['@php artisan migrate --force']);
$doc->replace('/require/php', '^8.2');
$doc->toFile(base_path('composer.json'));
Everything you did not touch — key order, 4-space indent, LF endings, the trailing newline — survives. That round-trip is the whole reason to pick this package over json_decode() + json_encode().
A good first internal use: a php artisan config:set-json style command, or a Composer plugin that adds a script entry to the host project, where a diff should show one added line, not a whole-file reformat.
Load → mutate → validate → atomic write
This is the canonical day-to-day flow. Because toFile() writes directly, wrap it yourself in Laravel:
use Illuminate\Support\Facades\File;
use JohnStevenson\JsonWorks\Document;
function patchJson(string $path, callable $mutator): void
{
$doc = new Document();
$doc->fromFile($path);
$mutator($doc);
$doc->validate(); // throws on invalid document
$tmp = tempnam(dirname($path), 'json-');
file_put_contents($tmp, $doc->toJson());
rename($tmp, $path); // atomic on same filesystem
}
rename() over file_put_contents() into the original path protects you against a half-written composer.json if the process dies mid-write. Combine with File::get()/LockableFile if you expect concurrent writers.
Pointer-builder helper — escape every segment
Manually building pointers is where bugs come from. Centralise it:
function ptr(string ...$segments): string
{
return implode('', array_map(
fn ($s) => '/' . str_replace(['~', '/'], ['~0', '~1'], (string) $s),
$segments
));
}
// composer package names contain "/" — this is required, not optional:
$doc->replace(ptr('require', 'laravel/framework'), '^11.0');
// => /require/laravel~1framework
Query-then-branch, rather than blind mutation
query() throws when the pointer does not resolve, so treat it as an existence probe:
try {
$current = $doc->query('/extra/merge-plugin/include');
} catch (\Throwable $e) {
$doc->add('/extra/merge-plugin/include', []);
}
If you're doing several conditional edits, a small has($doc, $pointer) wrapper around a try/catch keeps the command classes readable. Prefer reading the value once and doing in-memory comparison before deciding to call replace() — a no-op replace() still rewrites the node and may normalise subtle formatting inside that node.
Array append and array edits
- is the JSON Pointer append token:
$doc->add('/autoload/psr-4/App\\/-', 'database/seeders/');
Array indices are resolved against the document at call time. When removing multiple elements from the same array, iterate in descending index order or re-read the array between operations, otherwise your indices shift out from under you.
Build brand-new documents with explicit formatting
When the target file does not exist yet, start from an empty document, set the formatting you want, then add nodes:
$doc = new Document();
$doc->fromJson('{}');
$doc->setIndent(' '); // verify the exact setter in your pinned version
$doc->setLineEndings("\n");
$doc->add('/require', ['php' => '^8.2']);
$doc->toJson();
Pick indentation and line endings that match the rest of the repo (composer.json, .editorconfig) so the first write doesn't create a lint diff.
Wrap it in a Laravel service
Inject a thin JsonPatcher with patch(), read(), validateFile() methods rather than sprinkling new Document() through commands. That gives you one place to add logging, backups (.bak copy before rename), and your own pointer helper — and one place to swap the library out if you need to fork it.
Testing pattern
The most valuable test is a fidelity test: load a representative fixture, call toJson(), assert the output is byte-identical to the input. Then add tests per mutation asserting the diff is what you expect — not just that decode-then-assert passes, which would hide reformatting.
#/foo/bar is not accepted; only plain /foo/bar. Strip the # yourself if you're piping in a URI fragment.$doc->replace('', $newValue) replaces the entire document; query('') returns the whole tree.- only works for arrays. Using it on an object is an error. Object additions always append at the end of the object; they never insert between existing keys.{} → { ... }. Assert against fixtures with empty containers before trusting it on real files.validate() on inputs you don't control and fail loudly rather than silently picking the last value.1.0 vs 1, 12345678901234567890). Round-trip a fixture containing both before you rely on it.\u00e9 and é in the source stay in their original form; don't expect the library to normalise them. Same for escaped slashes.setLineEndings() explicitly for files you create from scratch.toFile() is not atomic and takes no lock. Wrap it (temp file + rename()), as in the pattern above, or you risk a truncated config file on a crashed process.query() throws on a missing pointer. There is no null-on-miss mode. Always try/catch or pre-check; a swallowed exception inside a loop is a common source of "my edit silently didn't apply".$.. or require.* will not work — iterate in PHP and build pointers./ in keys. Composer package names are the everyday example: /require/laravel~1framework. Same for ~ itself (~0). Use the ptr() helper everywhere.src/ for your pinned version), and surface getMessage() to the user or your log; it tells you the offset and the problem, which is far better than json_decode()'s null.composer.json and app config; not for multi-hundred-MB JSON dumps.replace() the affected subtree — or add children one by one if you need per-line diffs.JsonPatcher.composer.json, keep a couple of round-trip fixtures in your own test suite, and assume you may need to vendor a fork for PHP-version compatibility issues. The upside is that the format-preserving core is small and stable, so the risk is mostly "nobody will fix it for you", not "the API will churn".How can I help you explore Laravel packages today?