fuzzyfox / json
An opinionated JSON helper library for PHP
Requires
- php: ^8.5
Requires (Dev)
- laravel/pint: ^1.30.5
- pestphp/pest: ^5.1.1
- pestphp/pest-plugin-type-coverage: ^5.0.2
- phpstan/phpstan: ^2.2.8
- rector/rector: ^2.6.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
An opinionated JSON helper library for PHP. It throws instead of returning
false, it gives you a decoded object you can actually chain calls on, and it
ships a PHPStan extension that reads the shape of your JSON so $order->id is
an int and not a mixed.
use FuzzyFox\Json; $order = Json::object('{"id":1001,"customer":{"name":"Ada"},"items":[{"sku":"A","qty":2}]}'); $order->id; // 1001, typed as int by PHPStan $order->customer->name; // 'Ada' $order->items[0]->sku; // 'A' $order->value('coupon', 'NONE'); // 'NONE', the key is absent $order->except('items')->toJson(); // '{"id":1001,"customer":{"name":"Ada"}}' // Or demand a type, and throw the moment the payload disagrees. $order->int('id'); // 1001 $order->object('customer')->string('name'); // 'Ada' $order->string('id'); // UnexpectedJsonValue: Expected JSON string, got int.
Why
json_decode() hands back a stdClass or a nested array and leaves the rest to
you. Three things get annoying quickly:
| Problem | What this library does |
|---|---|
json_decode() returns null on a syntax error, which is also a valid result for "null". You reach for JSON_THROW_ON_ERROR on every call. |
Every method throws on failure. Json::decode('') returns null and nothing else does. |
An empty JSON object round-trips through a PHP array as [], not {}. |
JsonObject keeps the JSON shape. It stays {} even after you remove every key. |
The decoded value is mixed, so your IDE and your static analyser go quiet. |
Typed decoders and typed accessors return one type, and the bundled PHPStan extension infers the object shape from literal JSON. |
A field that changed type upstream surfaces as a TypeError somewhere far from the JSON. |
Typed accessors fail at the read, naming the key's actual type. |
Installation
composer require fuzzyfox/json
Requires PHP 8.5 or later. No runtime dependencies.
Decoding
Json::decode() accepts any JSON value and returns whichever PHP type the JSON
describes. A JSON object becomes a JsonObject; a JSON array
becomes a PHP list, with any object inside it coerced too.
use FuzzyFox\Json; Json::decode('{"a":1}'); // JsonObject Json::decode('[1,2]'); // [1, 2] Json::decode('"hello"'); // 'hello' Json::decode('1.5'); // 1.5 Json::decode('true'); // true Json::decode('null'); // null Json::decode(''); // null
Typed decoders
When you already know what type the JSON must hold, use the typed decoder for
it. Each returns exactly one type, so there is no union to unpick and no manual
is_*() check to satisfy your analyser.
Json::object('{"name":"Ada"}'); // JsonObject Json::array('[1,2]'); // [1, 2] Json::string('"hello"'); // 'hello' Json::int('42'); // 42 Json::float('1.5'); // 1.5 Json::bool('true'); // true
Anything else throws UnexpectedJsonValue:
Json::object('[1,2]'); // UnexpectedJsonValue: Expected JSON object, got array. Json::int('1.5'); // UnexpectedJsonValue: Expected JSON integer, got float. Json::bool('"true"'); // UnexpectedJsonValue: Expected JSON boolean, got string.
Two rules worth knowing:
int()refuses a decimal. Truncating loses data, soJson::int('1.5')throws rather than returning1. Usefloat()for those numbers.float()accepts a whole number. JSON has one number type, soJson::float('42')returns42.0.
Every decoder takes the same $depth and $flags arguments as json_decode():
Json::decode($json, depth: 64, flags: JSON_BIGINT_AS_STRING);
Encoding
Json::encode(['a' => 1]); // '{"a":1}' Json::encode([1, 2, 3]); // '[1,2,3]' Json::encode(JsonObject::make()); // '{}', not '[]' Json::encode(['a' => 1], JSON_PRETTY_PRINT);
Json::encode() throws JsonException for anything that cannot become JSON — a
resource, an INF, a NAN, or a string that is not valid UTF-8.
JsonObject
JsonObject is what a decoded JSON object becomes. It is mutable, chainable,
and knows it is an object rather than an array.
Reading
Three ways in, all equivalent apart from defaults:
$user = Json::object('{"name":"Ada","age":null}'); $user->name; // 'Ada' — property access $user['name']; // 'Ada' — array access $user->value('name'); // 'Ada' — method access $user->city; // null $user->value('city', 'London'); // 'London' $user->value('city', fn () => slow()); // the closure runs only if the key is absent
Only value() takes a default. A key holding null returns null, not the
default — the key is present, and this library does not conflate the two.
Nested objects are coerced all the way down, so you can chain without checking:
$user = Json::object('{"address":{"city":{"name":"London"}}}'); $user->address->city->name; // 'London' $user->address->value('city')['name']; // 'London', mix and match freely
Typed accessors
All three readers above return mixed. The typed accessors mirror the
typed decoders at the level of a single key: each returns one
type, and throws UnexpectedJsonValue when the key holds another.
$order = Json::object('{"id":1001,"customer":{"name":"Ada"},"items":[{"sku":"A"}],"total":49.99}'); $order->int('id'); // 1001 — int, not mixed $order->string('customer'); // throws — Expected JSON string, got object. $order->object('customer')->string('name'); // 'Ada' $order->array('items')[0]->string('sku'); // 'A' $order->float('total'); // 49.99 $order->bool('paid'); // throws — Expected JSON boolean, got null.
This buys you two things over value(). The obvious one is that a malformed
payload fails at the read, naming the key's actual type, instead of surfacing
three frames later as a TypeError on something unrelated. The subtler one is
that the narrowing comes from the declared return type, so it holds even when
PHPStan has no shape to work from — $order->string('name') is a string on
any JsonObject, however it was built.
The same two number rules as the decoders apply: int() refuses a decimal
rather than truncating, and float() accepts a whole number.
$order->float('id'); // 1001.0 — widened, the value is kept $order->int('total'); // throws — Expected JSON integer, got float.
An absent key throws. That is the point of the accessors, so reading an optional key means saying what it falls back to:
$order->string('coupon', 'NONE'); // 'NONE' $order->int('discount', 0); // 0 $order->object('meta', []); // an empty JsonObject $order->array('notes', []); // [] $order->string('coupon'); // throws — Expected JSON string, got null.
The default only covers an absent key. A key that is present and holds null
still throws, matching the way value() keeps the two apart:
$user = Json::object('{"age":null}'); $user->value('age', 36); // null — the key is present $user->int('age', 36); // throws — Expected JSON integer, got null.
For object(), an array or stdClass default is read as the attributes of one,
so [] gives you an empty object to chain on rather than a type error.
Presence and emptiness
The presence helpers follow Laravel's semantics, and each accepts either an array of keys or several arguments.
$user = Json::object('{"name":"Ada","email":"","age":null,"tags":[]}'); $user->has('age'); // true — the key is present, even holding null $user->has('name', 'age'); // true — every key must be present $user->hasAny('name', 'city'); // true — at least one key is present $user->missing('city', 'phone'); // true — no key is present $user->filled('name'); // true $user->filled('email'); // false — an empty (or whitespace) string $user->filled('age'); // false — null is never filled $user->filled('tags'); // true — a list is always filled, even empty $user->anyFilled('email', 'name'); // true $user->isNotFilled('email', 'city'); // true $user->isEmpty(); // false $user->isNotEmpty(); // true
has() and isset() deliberately disagree, because PHP's isset() reads
null as absent:
$user->has('age'); // true isset($user->age); // false
Deriving new objects
only() and except() return a new object and leave the original alone.
$user = Json::object('{"name":"Ada","age":36,"city":"London"}'); $user->only('name', 'age')->toJson(); // '{"name":"Ada","age":36}' $user->except('city')->toJson(); // '{"name":"Ada","age":36}' $user->toJson(); // unchanged
Writing
Writes go through the same coercion as decoding, so an associative array you
assign becomes a nested JsonObject.
$user = JsonObject::make(); $user->name = 'Ada'; $user['address'] = ['city' => 'London']; $user->fill(['age' => 36]); $user->address->city; // 'London' $user->toJson(); // '{"name":"Ada","address":{"city":"London"},"age":36}' unset($user->age, $user['address']); $user->toJson(); // '{"name":"Ada"}'
One caveat: reading a list gives you a copy, so mutate and write it back.
$tags = $user->tags; $tags[] = 'new'; $user->tags = $tags; // required — the read handed you a copy
Iterating and counting
JsonObject implements IteratorAggregate and Countable, one level deep.
foreach (Json::object('{"a":1,"b":2}') as $key => $value) { echo "$key=$value "; // a=1 b=2 } count(Json::object('{"a":1,"b":{"c":2}}')); // 2 Json::object('{"b":1,"a":2}')->keys(); // ['b', 'a'], in JSON order Json::object('{"b":1,"a":2}')->values(); // [1, 2]
Converting out
| Method | Result |
|---|---|
toJson() |
JSON string. Keeps the object/array distinction. |
toPrettyJson() |
The same, with JSON_PRETTY_PRINT added to your flags. |
all() |
Attributes one level deep. Nested objects stay JsonObject. |
toArray() |
Plain arrays at every level. No JsonObject left. |
jsonSerialize() |
A stdClass, so json_encode() does the right thing. |
The one place the distinction matters:
$data = Json::object('{"meta":{}}'); $data->toJson(); // '{"meta":{}}' Json::encode($data->toArray()); // '{"meta":[]}' — the array form cannot express it json_encode($data); // '{"meta":{}}' — jsonSerialize() preserves it
Reach for toArray() when you want plain data, and toJson() when the wire
format matters.
Coercing your own objects
JsonObject::coerce() reads foreign objects through their methods rather than
through an interface, so Laravel collections and Arrayable-shaped value
objects work without this library depending on Laravel.
JsonObject::coerce((object) ['a' => 1]); // JsonObject JsonObject::coerce(['a' => 1]); // JsonObject — the array has string keys JsonObject::coerce([1, 2]); // [1, 2] — the array is a list JsonObject::coerce($collection); // JsonObject, via all() JsonObject::coerce($valueObject); // JsonObject, via toArray() JsonObject::coerce('hello'); // 'hello'
The order it tries is all(), then toArray(), then iterator_to_array() for
anything iterable. Everything else is left as it is.
Static analysis
The package ships a PHPStan extension and registers it automatically through phpstan/extension-installer. Without that installer, include it by hand:
includes: - vendor/fuzzyfox/json/extension.neon
The extension resolves literal JSON into an object shape, so the whole tree is typed without a single annotation:
$order = Json::decode('{"id":1001,"items":[{"sku":"A"}]}'); // JsonObject&object{id: 1001, items: array{JsonObject&object{sku: 'A'}}} $order->id; // int $order->items[0]->sku; // string $order->value('id'); // int — method calls resolve against the shape too
Two things fall out of this that PHPStan cannot do with json_decode() alone:
{}and[]stay distinct. PHPStan's own inference renders both asarray{}; here they areJsonObject&object{}andarray{}.- A method call is not opaque.
value(),offsetGet()andall()resolve against the shape, and an absent key resolves to the type of your default rather than raising an error.
Annotate a shape yourself when the JSON arrives at runtime:
/** @param JsonObject&object{id: int, user: JsonObject&object{email: string}} $order */ function ship(JsonObject $order): void { $order->user->email; // string }
Without a shape, reads degrade to mixed rather than erroring — JsonObject is
registered as a universal object crate, so arbitrary keys are never reported as
undefined properties.
The typed accessors are the escape hatch when no shape is available, since their narrowing comes from the declared return type rather than from inference:
function ship(JsonObject $order): void // no shape to work from { $order->value('reference'); // mixed $order->string('reference'); // string }
Errors
| Exception | Meaning |
|---|---|
FuzzyFox\Exceptions\JsonException |
The string is not valid JSON, or the value cannot be encoded. Extends PHP's \JsonException. |
FuzzyFox\Exceptions\UnexpectedJsonValue |
The JSON is valid but holds a different type than you asked for. Extends \UnexpectedValueException. |
Catching them separately tells a malformed response apart from a surprising one:
try { $order = Json::object($response); } catch (UnexpectedJsonValue $e) { // Valid JSON, wrong shape — the API changed. } catch (JsonException $e) { // Not JSON at all — a gateway error page, most likely. }
Because JsonException extends PHP's own, a single catch (\JsonException $e)
also covers anything thrown by JSON_THROW_ON_ERROR elsewhere in your stack.
Helpers
use function FuzzyFox\value; value('Ada'); // 'Ada' value(fn () => 'Ada'); // 'Ada' value(fn ($n) => $n * 2, 21); // 42
value() is namespaced rather than global on purpose. A global value() behind
a function_exists() guard yields to whichever package autoloads first, which
would make this library's behaviour depend on autoload order.
Development
composer install composer test # pest --parallel composer coverage # pest --coverage --min=100 composer lint # phpstan analyse, then pest --type-coverage composer format # rector process, then pint --parallel
Run a single test with vendor/bin/pest --filter "decodes an object".
The PHPStan extension is tested the way a consumer meets it: phpstan analyse
runs over tests/types/, where fixtures full of assertType() calls pin the
inferred types. Add an assertion there whenever you change what the extension
infers.
Contributing
Contributions are welcome. Please open an issue before starting on anything
substantial, keep the test suite at 100% coverage, and run composer lint and
composer format before opening a pull request.
License
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.