Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-08-04

### Changed

- **BREAKING**: `Json::decode()` now rejects values whose JSON type does not match the declared PHP type instead of
coercing them. Both promoted constructor parameters and plain properties throw a `JsonError` naming the parameter or
property, its class, the expected type and what arrived:

```
Expected bool for parameter "pinRequired" of class Body, got string
```

Previously, values bound to constructor parameters were passed through unchecked. The instance is created through the
Reflection API, which always binds arguments in weak mode regardless of any `declare(strict_types=1)`, so PHP silently
coerced them: `{"pinRequired":"not a boolean"}` decoded to `true`, `{"code":42}` to `'42'`, and `{"articleType":50.9}`
to `50` plus a deprecation notice. Decoding reported success while handing the caller a value the payload never
contained.

The check mirrors strict-mode parameter binding:

| Declared type | Accepted | Rejected |
| --- | --- | --- |
| `bool` | `true`, `false` | everything else, including `"true"`, `"false"`, `0`, `1` |
| `int` | int | float (including `50.0`), numeric string, bool, everything else |
| `float` | float, **and int** | numeric string, bool, everything else |
| `string` | string | int, float, bool, everything else |
| `true` / `false` / `null` | exactly that value | everything else |
| `mixed` | anything | – |

Widening an int to a float keeps working, because that is the one conversion strict mode itself performs: a payload
sending a whole amount as `100` still decodes into `float $amount` as `100.0`.

Nullable parameters and properties still accept `null`, non-nullable ones no longer do, and omitted optional
parameters still take their default without being checked.

- **BREAKING**: Constructor parameters and properties typed `iterable`, `object` or `callable` are now rejected with
`Unsupported type "iterable" for parameter "value" of class …` instead of being passed through. `json_decode()` cannot
produce a value that meaningfully satisfies them.

### Upgrading

Payloads that decode today may start throwing `JsonError` at runtime rather than failing analysis. Codebases are likely
to depend on the old coercion without knowing it, because the library was already strict about the very same JSON when
the target field happened to be a plain property rather than a promoted constructor parameter:

```php
final class ViaCtor { public function __construct(public bool $flag = false) {} }
final class ViaProp { public bool $flag = false; }

Json::decode('{"flag":"not a boolean"}', ViaCtor::class)->flag;
// before: true — after: JsonError
Json::decode('{"flag":"not a boolean"}', ViaProp::class)->flag;
// before: TypeError — after: JsonError
```

One input produced two answers, decided by a detail of the target class that has nothing to do with the JSON. Both paths
now fail the same way, so a codebase may have been relying on the lax behavior in one place and not in another.

If a producer legitimately sends a scalar in a different shape than the consumer declares — numbers as strings, for
instance — declare the parameter with the type that is actually sent and convert it in your own code.

[0.2.0]: https://github.com/eventjet/eventjet-json/compare/v0.1.3...v0.2.0
55 changes: 55 additions & 0 deletions src/Json.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,26 @@ private static function populateProperty(object $object, string $jsonKey, mixed
$value = $newValue;
}
}
self::assertPropertyType($object, $property, $value);
$object->$property = $value; // @phpstan-ignore-line
}

private static function assertPropertyType(object $object, string $property, mixed $value): void
{
$type = (new ReflectionProperty($object, $property))->getType();
if (!$type instanceof ReflectionNamedType || !$type->isBuiltin()) {
return;
}
if ($value === null && $type->allowsNull()) {
return;
}
self::assertBuiltinType(
$type->getName(),
$value,
sprintf('property "%s" of class %s', $property, $object::class),
);
}

private static function getPropertyNameForJsonKey(object $object, string $key): string
{
foreach ((new ReflectionObject($object))->getProperties() as $property) {
Expand Down Expand Up @@ -316,6 +333,13 @@ private static function createConstructorArgumentForNamedType(ReflectionParamete
return self::createConstructorArgumentForArrayType($parameter, $value);
}
if ($type->isBuiltin()) {
$class = $parameter->getDeclaringClass();
assert($class !== null);
self::assertBuiltinType(
$typeName,
$value,
sprintf('parameter "%s" of class %s', $paramName, $class->getName()),
);
return $value;
}
if (enum_exists($typeName)) {
Expand Down Expand Up @@ -354,6 +378,37 @@ private static function createConstructorArgumentForNamedType(ReflectionParamete
return self::instantiateClass($typeName, $value);
}

/**
* Constructor arguments are bound by the Reflection API, which always uses weak mode and would silently coerce
* them, and property values are assigned dynamically. Check both against the declared type ourselves so that the
* two paths reject the same payloads with the same error.
*
* @param string $subject Describes the target the value is checked against, e.g. `parameter "age" of class Person`.
*/
private static function assertBuiltinType(string $typeName, mixed $value, string $subject): void
{
$isValid = match ($typeName) {
'mixed' => true,
'bool' => is_bool($value),
'int' => is_int($value),
// Widening an int to a float is the one conversion strict mode itself performs.
'float' => is_float($value) || is_int($value),
'string' => is_string($value),
'array' => is_array($value),
'true' => $value === true,
'false' => $value === false,
// Nulls are returned before we get here if the type allows them, so nothing left can satisfy this type.
'null' => false,
default => throw JsonError::decodeFailed(
sprintf('Unsupported type "%s" for %s', $typeName, $subject),
),
};
if ($isValid) {
return;
}
throw JsonError::decodeFailed(sprintf('Expected %s for %s, got %s', $typeName, $subject, gettype($value)));
}

private static function createConstructorArgumentForUnionType(): mixed
{
throw JsonError::decodeFailed('Union types are not supported');
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/Fixtures/HasBoolProperty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class HasBoolProperty
{
public bool $value = false;
}
12 changes: 12 additions & 0 deletions tests/unit/Fixtures/TakesBool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesBool
{
public function __construct(public bool $value = false)
{
}
}
15 changes: 15 additions & 0 deletions tests/unit/Fixtures/TakesCallable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesCallable
{
public mixed $value;

public function __construct(callable $value)
{
$this->value = $value;
}
}
12 changes: 12 additions & 0 deletions tests/unit/Fixtures/TakesFalse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesFalse
{
public function __construct(public false $value = false)
{
}
}
12 changes: 12 additions & 0 deletions tests/unit/Fixtures/TakesFloat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesFloat
{
public function __construct(public float $value = 0.0)
{
}
}
12 changes: 12 additions & 0 deletions tests/unit/Fixtures/TakesInt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesInt
{
public function __construct(public int $value = 0)
{
}
}
15 changes: 15 additions & 0 deletions tests/unit/Fixtures/TakesIterable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesIterable
{
/**
* @param iterable<array-key, mixed> $value
*/
public function __construct(public iterable $value = [])
{
}
}
12 changes: 12 additions & 0 deletions tests/unit/Fixtures/TakesMixed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesMixed
{
public function __construct(public mixed $value = null)
{
}
}
12 changes: 12 additions & 0 deletions tests/unit/Fixtures/TakesNull.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesNull
{
public function __construct(public null $value = null)
{
}
}
12 changes: 12 additions & 0 deletions tests/unit/Fixtures/TakesObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesObject
{
public function __construct(public object $value)
{
}
}
12 changes: 12 additions & 0 deletions tests/unit/Fixtures/TakesString.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesString
{
public function __construct(public string $value = '')
{
}
}
12 changes: 12 additions & 0 deletions tests/unit/Fixtures/TakesTrue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class TakesTrue
{
public function __construct(public true $value = true)
{
}
}
Loading
Loading