PHPackages                             fuzzyfox/json - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. fuzzyfox/json

ActiveLibrary

fuzzyfox/json
=============

An opinionated JSON helper library for PHP

v1.0.0(today)01↑2900%MPL-2.0PHPPHP ^8.5CI passing

Since Aug 25Pushed todayCompare

[ Source](https://github.com/fuzzyfox/json-php)[ Packagist](https://packagist.org/packages/fuzzyfox/json)[ RSS](/packages/fuzzyfox-json/feed)WikiDiscussions main Synced today

READMEChangelog (1)Dependencies (5)Versions (2)Used By (0)

JSON for PHP
============

[](#json-for-php)

[![CI](https://github.com/fuzzyfox/json-php/actions/workflows/ci.yml/badge.svg)](https://github.com/fuzzyfox/json-php/actions/workflows/ci.yml)[![Latest Version on Packagist](https://camo.githubusercontent.com/ce34e3bc12f9ea55e2036ecc691e70f3deadb3a27537c7ffa96f85a4f9cd1386/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f66757a7a79666f782f6a736f6e2e737667)](https://packagist.org/packages/fuzzyfox/json)[![Total Downloads](https://camo.githubusercontent.com/db9439f63b56cb5f1541e0c3581b3f6674c9cbbf8b3692742f69023277897940/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f66757a7a79666f782f6a736f6e2e737667)](https://packagist.org/packages/fuzzyfox/json)[![PHP Version](https://camo.githubusercontent.com/32727495adf9973fbc2cb526268cb1592a7ccbfc0e327e30c17a07e5b8715a5e/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f66757a7a79666f782f6a736f6e2e737667)](https://packagist.org/packages/fuzzyfox/json)[![License](https://camo.githubusercontent.com/b754e667698a3af7d37f84a7158f7a9eb774654cc0a502566de33310ea5f489b/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f66757a7a79666f782f6a736f6e2d7068702e737667)](https://github.com/fuzzyfox/json-php/blob/main/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
---

[](#why)

`json_decode()` hands back a `stdClass` or a nested array and leaves the rest to you. Three things get annoying quickly:

ProblemWhat 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
------------

[](#installation)

```
composer require fuzzyfox/json
```

Requires PHP 8.5 or later. No runtime dependencies.

Decoding
--------

[](#decoding)

`Json::decode()` accepts any JSON value and returns whichever PHP type the JSON describes. A JSON object becomes a [`JsonObject`](#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

[](#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
--------

[](#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)

`JsonObject` is what a decoded JSON object becomes. It is mutable, chainable, and knows it is an object rather than an array.

### Reading

[](#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

[](#typed-accessors)

All three readers above return `mixed`. The typed accessors mirror the [typed decoders](#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

[](#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

[](#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

[](#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

[](#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

[](#converting-out)

MethodResult`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

[](#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
---------------

[](#static-analysis)

The package ships a PHPStan extension and registers it automatically through [phpstan/extension-installer](https://github.com/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](#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
------

[](#errors)

ExceptionMeaning`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
-------

[](#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
-----------

[](#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
------------

[](#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
-------

[](#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 .

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance100

Actively maintained with recent releases

Popularity2

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity50

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/2d49d84d9bd2c13d96b82780962eaf49f0ddf936bd475a1e1f99f4feda78793a?d=identicon)[fuzzyfox](/maintainers/fuzzyfox)

---

Top Contributors

[![fuzzyfox](https://avatars.githubusercontent.com/u/99770?v=4)](https://github.com/fuzzyfox "fuzzyfox (22 commits)")

---

Tags

jsonjson-parserphpphpstanphpstan-extensionstatic-analysis

###  Code Quality

TestsPest

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/fuzzyfox-json/health.svg)

```
[![Health](https://phpackages.com/badges/fuzzyfox-json/health.svg)](https://phpackages.com/packages/fuzzyfox-json)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
