Search by

fuzzyfox / json

fuzzyfox

An opinionated JSON helper library for PHP

v1.0.0 2026-08-25 14:25 UTC

This package is auto-updated.

Last update: 2026-08-25 14:27:31 UTC


README

CI Latest Version on Packagist Total Downloads PHP Version License

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, so Json::int('1.5') throws rather than returning 1. Use float() for those numbers.
  • float() accepts a whole number. JSON has one number type, so Json::float('42') returns 42.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 as array{}; here they are JsonObject&object{} and array{}.
  • A method call is not opaque. value(), offsetGet() and all() 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/.