Reject values that don't match the declared type - #8
Merged
Conversation
Builtin scalar constructor arguments were passed through unchecked. The
instance is created via 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.
Check the value against the declared ReflectionNamedType before
constructing instead, mirroring strict-mode parameter binding: int
widens to float, nothing else converts. Properties are checked the same
way before assignment, so a promoted constructor parameter and a plain
property no longer give two different answers for one input.
BREAKING CHANGE: payloads that decode today may start throwing JsonError.
rieschl
approved these changes
Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Json::decode()silently type-coerced scalar constructor arguments. It now rejects them with aJsonError.Nothing was thrown and nothing was logged — the caller received a well-formed object carrying a value the JSON never contained, and downstream code was then correct to trust it.
Why it happened
createConstructorArgumentForNamedType()passed builtin scalars straight through:Objects, enums and arrays were validated; scalars were not, leaving PHP's parameter binding as the only remaining check. That binding runs in weak mode, because
instantiateClass()constructs throughReflectionClass::newInstanceArgs().strict_typesis a property of the call-site opcode, and the Reflection API builds the call from C, so there is no user-land call site to read the flag from:The
declare(strict_types=1)at the top ofJson.phpbought nothing on this path. So the fix is to validate before constructing, not to change how the instance is constructed.The fix
assertBuiltinType()checks the value against the declaredReflectionNamedType, mirroring strict-mode parameter binding:booltrue,false"true","false",0,1int50.0), numeric string, bool, everything elsefloatstringtrue/false/nullmixedint → float keeps working, because that is the one widening strict mode itself permits — real APIs send a whole amount as
100, not100.0, and a consumer declaringfloat $amountrelies on it. It has its own named test.iterable,objectandcallableare now rejected withUnsupported type "iterable" for parameter "value" of class …rather than passed through;json_decode()cannot produce a value that meaningfully satisfies them.neverandvoidcan't be parameter types, so they fall into the same arm for free.Errors name the parameter, the class, the expected type and what arrived:
Properties too
The same JSON was already rejected when the target happened to be a plain property rather than a promoted constructor parameter — property assignment in
populateProperty()is strict, so it raised aTypeError:Two answers for one input, decided by a detail of the target class that has nothing to do with the JSON. Both paths now end in the same
JsonError, soassertPropertyType()runs the same check before the dynamic assignment. It only fires for builtin named types — class-, enum- and union-typed properties keep their existing behavior.Notes on the approach
src/Type/is not reused for the check. That layer models JSON types, not PHP parameter types:Type\Number::validateValue()accepts int and float alike, so it cannot tellint $xfromfloat $x— exactly the distinction this turns on. The check is written againstReflectionNamedTypedirectly.assertBuiltinType()takes the subject as one pre-formatted string (parameter "age" of class Person), so a nested path can be prefixed there later without touching the check. The existing messages don't carry one either, so this doesn't make adding it harder.Tests
One case per table row, accept and reject; int → float widening as its own named test;
50.9intoint; a nested object, so the failure survivesinstantiateClass()recursion;nullinto a nullable scalar vs. a non-nullable one; an omitted optional parameter whose default would itself fail the check, proving defaults aren't run through it; andViaCtor/ViaPropasserted to produce parallel messages.composer checkis green — cs-check, psalm, phpstan with strict-rules, 150 tests, infection at 100% MSI and 100% covered MSI.Backward compatibility
Breaking, and the point of the change. Payloads that decode today will start throwing at runtime rather than failing analysis. Codebases are likely to depend on the old coercion without knowing it, precisely because the library was already strict about the same JSON in the property case — so the lax behavior may have been relied on in one place and not another.
CHANGELOG.md(new file) carries the table and an upgrade note; to be released as0.2.0.