Skip to content

Require eventjet/json ^0.2 and PHP 8.3 - #3

Open
MidnightDesign wants to merge 2 commits into
upgrade-dev-toolchainfrom
bump-eventjet-json-0.2
Open

Require eventjet/json ^0.2 and PHP 8.3#3
MidnightDesign wants to merge 2 commits into
upgrade-dev-toolchainfrom
bump-eventjet-json-0.2

Conversation

@MidnightDesign

@MidnightDesign MidnightDesign commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Why

eventjet/incert 0.1.1 requires eventjet/json ^0.2.0. As long as this package pins ^0.1.2, the two are mutually exclusive and the eventjet-2014 app cannot install both, so the constraint has to move.

What changed in eventjet/json 0.2.0

One commit: "Reject values that don't match the declared type". The public API is untouched — Json::encode(), Json::decode() and JsonError are byte-identical in signature. What changed is that decode() now validates a value against the declared type before constructing the target object.

Previously, scalar constructor arguments were bound through ReflectionClass::newInstanceArgs(), whose parameter binding runs in weak mode regardless of declare(strict_types=1), so a payload could be silently coerced — "not a boolean" became true, 42 became '42', 50.9 became 50. Nothing was thrown and nothing was logged. 0.2.0 mirrors strict-mode binding instead and throws JsonError on a mismatch.

Declared type Accepted Rejected
bool true, false everything else, including "true", "false", 0, 1
int int float, numeric string, bool
float float, and int numeric string, bool
string string int, float, bool

intfloat widening still works; it is the one widening strict mode itself performs. The equivalent check for plain, non-promoted properties only converts a TypeError into a JsonError — that path was already strict. So the only genuinely new rejections are on scalar-typed promoted constructor parameters of a decode target.

The platform requirement moves too

The root php constraint goes from >=8.2 to >=8.3 in the same change, because the json bump moves this package's real minimum PHP version.

eventjet/json v0.2.0 requires php >=8.3, as does v0.1.3. Only v0.1.2 and earlier required >=8.1. Under the old ^0.1.2 constraint the root >=8.2 was therefore honest — on PHP 8.2, composer simply resolved json to v0.1.2. Under ^0.2 there is exactly one candidate, v0.2.0, and it needs 8.3, so leaving the root at >=8.2 would promise support the package cannot deliver, and composer require eventjet/openai on PHP 8.2 would fail to resolve:

- Root composer.json requires eventjet/json ^0.2 -> satisfiable by eventjet/json[v0.2.0].
- eventjet/json v0.2.0 requires php >=8.3 -> your php version (8.2.0) does not satisfy that requirement.

Verified by resolving the package against config.platform.php 8.2.0 (fails as above) and 8.3.0 (resolves cleanly to v0.2.0). eventjet/klarna made the identical change for the same reason.

The audit

The package has exactly one decode call site: OpenAi::createChatCompletion() (src/OpenAi.php:49) decodes the response body into CreateChatCompletionResponse. Every scalar-typed promoted parameter reachable from that class was checked against what the OpenAI chat-completions endpoint actually sends.

Class Parameter Declared Wire Verdict
CreateChatCompletionResponse id string string OK
created int integer (unix ts) OK
model string string OK
object string "chat.completion" OK
choices array array OK
Choice finish_reason string string, non-null on non-streaming OK
index int integer OK
message class type object unaffected
logprobs Logprobs|null null nullable named type, unaffected
ChatCompletionResponseMessage content string|null string or null OK
refusal string|null string or null OK
role string "assistant" OK

Specific hazards checked and cleared:

  • Nullability. content and refusal are the two fields OpenAI documents as nullable, and both are already declared nullable. finish_reason is non-null on non-streaming completions, which is the only mode this client supports.
  • Floats. There are none. No temperature/top_p-style float parameter exists on any decode target, so the 1 vs "1" hazard does not arise.
  • Token counts / usage integers. Not decoded at all — CreateChatCompletionResponse has no usage field, so usage and its nested counts never reach the type check.
  • Streaming and error envelopes. Neither is decoded. A non-200 response becomes a RuntimeException before any decoding happens, and there is no streaming/delta path.
  • Unknown keys. usage, system_fingerprint, service_tier, annotations and tool_calls are skipped by the decoder rather than assigned, so they cannot trip the new check.

No source change is required.

This was verified empirically, not only by reading. Verbatim gpt-4o-mini response bodies — a plain completion, a refusal instead of content, multiple choices with finish_reason length and content_filter, and a tool_calls response with content: null — were decoded under both 0.1.2 and 0.2.0 with identical results. The payloads 0.2.0 newly rejects are shapes the API does not produce:

### eventjet/json 0.1.2 (before)      ### eventjet/json 0.2.0 (after)
  OK    created as a numeric string     THROW  Expected int for parameter "created" of class CreateChatCompletionResponse, got string
  OK    index as a numeric string       THROW  Expected int for parameter "index" of class Choice, got string
  OK    id as a number                  THROW  Expected string for parameter "id" of class CreateChatCompletionResponse, got integer

Two pre-existing issues (not regressions, not fixed here)

Both were confirmed to fail identically under 0.1.2, so neither is caused by this bump, and neither is reachable through this client:

  1. ChatCompletionTokenLogprob::$logprob and TopLogprob::$logprob are declared float|int, and the decoder rejects union-typed constructor parameters outright (Union types are not supported — present in 0.1.2 too). Unreachable, because CreateChatCompletionRequest exposes no logprobs flag, so choices[].logprobs is always null.
  2. A message without a refusal key fails with Missing required constructor argument "refusal". The current API always sends the key.

Both are worth fixing eventually, but out of scope for a dependency bump.

Verification

Gate Result
composer check-deps pass — no unknown symbols
composer cs-check pass — 0 of 22 files need fixing
composer phpstan pass — no errors
composer psalm pass — no errors, 100% type inference
composer phpunit pass — 6 tests, 7 assertions, 1 skipped (integration test needs OPENAI_API_KEY)
composer infection skipped — no source code changed

All five were re-run after the php: >=8.3 change and still pass. composer show eventjet/json resolves to v0.2.0, and no transitive dependency blocks the upgrade.

Stack

This PR is stacked on top of #4, the dev-toolchain upgrade, which merges first.

That upgrade is what makes the gates above meaningful. Originally they had to be run on PHP 8.3 rather than the host's 8.4, because the toolchain this package pinned (vimeo/psalm ^5.10, capped at 5.26.1) does not support 8.4 and crashes during scanning with InvalidArgumentException: $value must be a scalar. With #4 underneath, all five gates were re-run natively on the host's PHP 8.4 and pass, with no platform override.

Note that the root php constraint stays at >=8.3 here: #4 modernizes require-dev only and does not move the package's own minimum.

@MidnightDesign
MidnightDesign requested a review from rieschl August 5, 2026 10:26
@MidnightDesign MidnightDesign changed the title Require eventjet/json ^0.2 Require eventjet/json ^0.2 and PHP 8.3 Aug 5, 2026
eventjet/incert 0.1.1 requires eventjet/json ^0.2.0. As long as this
package pins ^0.1.2, the two are mutually exclusive and the eventjet-2014
app cannot install both, so the constraint has to move.

eventjet/json 0.2.0 contains a single change: "Reject values that don't
match the declared type". The public API is untouched -- Json::encode(),
Json::decode() and JsonError are byte-identical in signature. What
changed is that decode() now validates a value against the declared type
before constructing the target object.

Previously, scalar constructor arguments were bound through
ReflectionClass::newInstanceArgs(), whose parameter binding runs in weak
mode regardless of declare(strict_types=1), so a payload could be
silently coerced: "not a boolean" became true, 42 became '42', 50.9
became 50. 0.2.0 mirrors strict-mode binding instead and throws
JsonError on a mismatch. int -> float widening still works, as it does
in strict mode. The equivalent check for plain, non-promoted properties
only converts a TypeError into a JsonError; that path was already
strict. So the only genuinely new rejections are on scalar-typed
promoted constructor parameters of a decode target.

Audit
-----
The package has exactly one decode call site: OpenAi::createChatCompletion()
decodes the response body into CreateChatCompletionResponse. Every
scalar-typed promoted parameter reachable from that class was checked
against what the OpenAI chat-completions endpoint actually sends:

  CreateChatCompletionResponse: id (string), created (int), model
  (string), object (string), choices (array)
  Choice: finish_reason (string), index (int)
  ChatCompletionResponseMessage: content (?string), refusal (?string),
  role (string)

All of them match the wire format. finish_reason is non-null on
non-streaming completions, and the package does not decode streaming
chunks or error envelopes -- a non-200 response is turned into a
RuntimeException before any decoding happens. content and refusal, the
two fields OpenAI documents as nullable, are already declared nullable.
There are no float-typed parameters, and usage/token counts are not
decoded at all, since the response DTO has no usage field. Unknown keys
(usage, system_fingerprint, service_tier, annotations, tool_calls) are
skipped by the decoder rather than assigned. No source change is
required.

Verified by decoding verbatim gpt-4o-mini response bodies (plain
completion, refusal, multiple choices with finish_reason length and
content_filter, and a tool_calls response) under both 0.1.2 and 0.2.0
and confirming identical results. The payloads 0.2.0 newly rejects --
created or index as a numeric string, id as a number -- are shapes the
API does not produce.

Two pre-existing decode failures were found and confirmed to predate
this bump by running the same payloads against 0.1.2. Neither is a
regression and neither is reachable through this client:

  - ChatCompletionTokenLogprob::$logprob and TopLogprob::$logprob are
    declared float|int, and the decoder rejects union-typed constructor
    parameters outright. Unreachable, because CreateChatCompletionRequest
    exposes no logprobs flag, so choices[].logprobs is always null.
  - A message without a refusal key fails with "Missing required
    constructor argument". The current API always sends the key.

Verification
------------
check-deps, cs-check, phpstan, psalm and phpunit all pass. They were run
on PHP 8.3 because the dev toolchain this package pins (psalm ^5.10,
which caps at 5.26.1) does not run on PHP 8.4. infection was skipped:
no source code changed.
The eventjet/json bump moves this package's minimum PHP version, so the
root constraint has to move with it.

eventjet/json v0.2.0 requires php >=8.3, as does v0.1.3. Only v0.1.2 and
earlier required >=8.1. Under the previous "^0.1.2" constraint, the root
"php": ">=8.2" was therefore honest: on PHP 8.2 composer simply resolved
eventjet/json to v0.1.2. Under "^0.2" there is exactly one candidate,
v0.2.0, and it needs 8.3 -- so the root constraint was promising support
this package can no longer deliver, and "composer require eventjet/openai"
on PHP 8.2 would fail to resolve rather than fail to install:

  - Root composer.json requires eventjet/json ^0.2 -> satisfiable by
    eventjet/json[v0.2.0].
  - eventjet/json v0.2.0 requires php >=8.3 -> your php version (8.2.0)
    does not satisfy that requirement.

Verified by resolving the package against config.platform.php 8.2.0
(fails as above) and 8.3.0 (resolves cleanly to eventjet/json v0.2.0).
check-deps, cs-check, phpstan, psalm and phpunit were re-run on PHP 8.3
after the change and all pass.

This only touches the root php constraint. The dev toolchain is stale
enough that it cannot run on PHP 8.4 at all -- psalm ^5.10 caps at
5.26.1, which crashes while scanning -- but that upgrade is a separate
concern and is deliberately left out of this change.
@MidnightDesign
MidnightDesign force-pushed the bump-eventjet-json-0.2 branch from 2805a3a to fe1ddf2 Compare August 5, 2026 11:34
@MidnightDesign
MidnightDesign changed the base branch from master to upgrade-dev-toolchain August 5, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant