From a0da9ce656b67ae03ce25fc0f75d15887fddbb31 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sun, 5 Jul 2026 13:14:05 +0200 Subject: [PATCH 1/2] feat(type-safety): document JSON empty-object vs empty-array trap PHP's [] and {} are the same empty array: json_encode([]) yields [], and json_decode('{}', true) === []. Strict JSON consumers (LLM tool-calling, JSON Schema) reject [] where {} is required. Add rules: use stdClass for empty JSON objects, decode without associative when a value must round-trip as an object, and normalise in the shared value object's constructor. Signed-off-by: Sebastian Mendel --- .../references/type-safety.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/skills/php-modernization/references/type-safety.md b/skills/php-modernization/references/type-safety.md index 17776d0..0dd112f 100644 --- a/skills/php-modernization/references/type-safety.md +++ b/skills/php-modernization/references/type-safety.md @@ -566,3 +566,52 @@ class UserRepository extends AbstractRepository } } ``` + +## JSON: Empty Object vs. Empty Array + +PHP conflates `[]` (empty array) and `{}` (empty object) in ways that break +strict JSON consumers — LLM tool-calling APIs (OpenAI, Ollama), JSON Schema +validators, and any peer that distinguishes the two types. + +**Two traps, one root cause — `[]` and `{}` are the same empty PHP array:** + +```php +// Trap 1 — encoding: an empty PHP array becomes a JSON array, never an object. +json_encode([]); // "[]" ← NOT "{}" +json_encode(['properties' => []]); // {"properties":[]} ← invalid where an object is required + +// Trap 2 — decoding with associative=true collapses an empty object to []. +json_decode('{}', true); // [] (array) ← type is now wrong +json_decode('{}', true) === []; // true +json_decode('{}'); // stdClass {} (object) ← preserved +``` + +A JSON Schema `properties` block, or a tool call's `arguments`, MUST serialise +as `{}` when empty. Sending `[]` gets the whole request rejected — Ollama, for +example, returns HTTP 400 `Value looks like object, but can't find closing '}' +symbol`. + +**Rules:** + +- For a field that must be a JSON **object**, represent "empty" as + `new stdClass()` (or `(object) []`), not `[]`. Normalise at the value's + construction site so every serialisation path is correct: + + ```php + if (($schema['properties'] ?? null) === []) { + $schema['properties'] = new stdClass(); // serialises as {} + } + ``` + +- When a decoded value must **round-trip** as an object, decode with + `json_decode($json)` (objects) — not `json_decode($json, true)` (which turns + an empty `{}` into `[]` and re-encodes it wrong on the next hop). + +- Prefer normalising in a **shared value object's constructor** over each + provider/serializer: adapters that read the raw property directly (not via a + `toArray()`) then get the correct type too, and `fromArray(toArray())` stays + idempotent. + +**Symptom to recognise:** an intermittent-looking 4xx from a JSON API that +actually correlates with *which optional/empty field is present* (e.g. only +fails when a parameterless tool is offered) — not with timing or connections. From fd68fa1d442dfb6f73c21289b4e572cf8d859b7d Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sun, 5 Jul 2026 13:35:55 +0200 Subject: [PATCH 2/2] docs(type-safety): US spelling, fully-qualified \stdClass, constructor example Address review: use American spelling (serialize/normalize) to match the rest of the references, fully-qualify \stdClass so snippets work inside namespaced files, and add a concrete constructor normalization example. Signed-off-by: Sebastian Mendel --- .../references/type-safety.md | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/skills/php-modernization/references/type-safety.md b/skills/php-modernization/references/type-safety.md index 0dd112f..df0bbf9 100644 --- a/skills/php-modernization/references/type-safety.md +++ b/skills/php-modernization/references/type-safety.md @@ -586,7 +586,7 @@ json_decode('{}', true) === []; // true json_decode('{}'); // stdClass {} (object) ← preserved ``` -A JSON Schema `properties` block, or a tool call's `arguments`, MUST serialise +A JSON Schema `properties` block, or a tool call's `arguments`, MUST serialize as `{}` when empty. Sending `[]` gets the whole request rejected — Ollama, for example, returns HTTP 400 `Value looks like object, but can't find closing '}' symbol`. @@ -594,12 +594,12 @@ symbol`. **Rules:** - For a field that must be a JSON **object**, represent "empty" as - `new stdClass()` (or `(object) []`), not `[]`. Normalise at the value's - construction site so every serialisation path is correct: + `new \stdClass()` (or `(object) []`), not `[]`. Normalize at the value's + construction site so every serialization path is correct: ```php if (($schema['properties'] ?? null) === []) { - $schema['properties'] = new stdClass(); // serialises as {} + $schema['properties'] = new \stdClass(); // serializes as {} } ``` @@ -607,11 +607,19 @@ symbol`. `json_decode($json)` (objects) — not `json_decode($json, true)` (which turns an empty `{}` into `[]` and re-encodes it wrong on the next hop). -- Prefer normalising in a **shared value object's constructor** over each +- Prefer normalizing in a **shared value object's constructor** over each provider/serializer: adapters that read the raw property directly (not via a `toArray()`) then get the correct type too, and `fromArray(toArray())` stays - idempotent. + idempotent: -**Symptom to recognise:** an intermittent-looking 4xx from a JSON API that + ```php + public function __construct(array|object $properties) + { + // Empty array would serialize as []; force an empty object to {}. + $this->properties = $properties === [] ? new \stdClass() : (object) $properties; + } + ``` + +**Symptom to recognize:** an intermittent-looking 4xx from a JSON API that actually correlates with *which optional/empty field is present* (e.g. only fails when a parameterless tool is offered) — not with timing or connections.