From de4ca4577a4046349d2e0d24c6a0102c6234d764 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 14 Jul 2026 12:19:28 -0400 Subject: [PATCH 1/4] fix(php): support non-nullable unions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP generation failed with "union are not supported" for any union that wasn't just nullable — a heterogeneous array like [1, "two", {...}] made the whole render throw. Unions are now rendered inline as PHP 8.0 union type declarations (e.g. `MixedClass|int|string`), so no named union class is emitted. The from/to/validate converters dispatch on the runtime type of the value with an if/elseif chain, testing the most specific checks first (instanceof for classes, enums and DateTime, then is_int before the float check, which also accepts integers) and throwing on unmatched values. Samples use the first member. On the JSON side classes and maps are stdClass and enums and dates are strings, with textual duplicates deduplicated in the declaration. This also adds the PHP reserved words to the renderer's forbidden names: the motivating fixture has a property named "mixed", which used to name its class `Mixed` — a compile error in PHP 8 (reserved type name), now renamed to `MixedClass`. Previously no reserved word was protected at all, so a JSON key like "class" or "print" with an object value generated uncompilable PHP. Generated output for the fixtures parses cleanly as PHP 8.0. Co-Authored-By: Claude Fable 5 --- .../src/language/Php/PhpRenderer.ts | 280 ++++++++++++++++-- .../quicktype-core/src/language/Php/utils.ts | 96 ++++++ test/unit/php-unions.test.ts | 67 +++++ 3 files changed, 424 insertions(+), 19 deletions(-) create mode 100644 test/unit/php-unions.test.ts diff --git a/packages/quicktype-core/src/language/Php/PhpRenderer.ts b/packages/quicktype-core/src/language/Php/PhpRenderer.ts index 816f03b53b..c8682f9199 100644 --- a/packages/quicktype-core/src/language/Php/PhpRenderer.ts +++ b/packages/quicktype-core/src/language/Php/PhpRenderer.ts @@ -20,21 +20,22 @@ import { type Sourcelike, maybeAnnotated } from "../../Source.js"; import { acronymStyle } from "../../support/Acronyms.js"; import { defined } from "../../support/Support.js"; import type { TargetLanguage } from "../../TargetLanguage.js"; -import type { - ClassProperty, - ClassType, - EnumType, - Type, +import { + type ClassProperty, + type ClassType, + type EnumType, + type Type, UnionType, } from "../../Type/index.js"; import { directlyReachableSingleNamedType, matchType, nullableFromUnion, + removeNullFromUnion, } from "../../Type/TypeUtils.js"; import type { phpOptions } from "./language.js"; -import { phpNameStyle, stringEscape } from "./utils.js"; +import { phpForbiddenClassNames, phpNameStyle, stringEscape } from "./utils.js"; export interface FunctionNames { readonly from: Name; @@ -65,6 +66,10 @@ export class PhpRenderer extends ConvenienceRenderer { super(targetLanguage, renderContext); } + protected forbiddenNamesForGlobalNamespace(): readonly string[] { + return phpForbiddenClassNames; + } + protected forbiddenForObjectProperties( _c: ClassType, _className: Name, @@ -88,8 +93,10 @@ export class PhpRenderer extends ConvenienceRenderer { return this.getNameStyling("enumCaseNamingFunction"); } - protected unionNeedsName(u: UnionType): boolean { - return nullableFromUnion(u) === null; + protected unionNeedsName(_u: UnionType): boolean { + // Unions are represented inline as PHP union type declarations; + // no named type is ever emitted for them. + return false; } protected namedTypeToNameForTopLevel(type: Type): Type | undefined { @@ -247,6 +254,179 @@ export class PhpRenderer extends ConvenienceRenderer { this.emitLine("}"); } + // Union members in runtime-dispatch order: the most specific check + // first, so that e.g. an int matches an integer member before a double + // member, and class instances are tested before the stdClass check a + // map member uses. `any` goes last because it matches everything. + private sortedUnionMembers(u: UnionType): { + members: readonly Type[]; + nullType: Type | null; + } { + const order = [ + "class", + "enum", + "date-time", + "uuid", + "map", + "array", + "bool", + "integer", + "double", + "string", + ]; + const [nullType, nonNulls] = removeNullFromUnion(u, (t) => { + const i = order.indexOf(t.kind); + return i === -1 ? order.length : i; + }); + return { members: Array.from(nonNulls), nullType }; + } + + // The PHP union type declaration for a non-nullable union, e.g. + // `MixedClass|int|string` (requires PHP 8.0). With `jsonSide` the + // types are those of the decoded JSON value instead, where classes + // and maps are stdClass and enums and dates are strings. + protected phpUnionType(u: UnionType, jsonSide: boolean): Sourcelike { + const labels: Record = jsonSide + ? { + any: "mixed", + array: "array", + bool: "bool", + class: "stdClass", + "date-time": "string", + double: "float", + enum: "string", + integer: "int", + map: "stdClass", + string: "string", + uuid: "string", + } + : { + any: "mixed", + array: "array", + bool: "bool", + "date-time": "DateTime", + double: "float", + integer: "int", + map: "stdClass", + string: "string", + uuid: "string", + }; + const { members, nullType } = this.sortedUnionMembers(u); + const parts: Sourcelike[] = []; + const seen = new Set(); + for (const member of members) { + let hint: Sourcelike; + const label = labels[member.kind]; + if (label !== undefined) { + // Members can render to the same PHP type, like a string + // and a UUID member — mention it only once. + if (seen.has(label)) continue; + seen.add(label); + hint = label; + } else if (member.kind === "class" || member.kind === "enum") { + hint = this.nameForNamedType(member); + } else { + throw new Error( + `PHP cannot represent union member type "${member.kind}"`, + ); + } + + if (parts.length > 0) parts.push("|"); + parts.push(hint); + } + + if (nullType !== null) parts.push("|null"); + return parts; + } + + // The runtime check deciding whether a value is this union member. + // Returns null for `any`, which matches everything. With `jsonSide` + // the value is a decoded JSON value instead of a PHP-side one. + private unionMemberTypeCheck( + t: Type, + expr: Sourcelike[], + jsonSide: boolean, + ): Sourcelike[] | null { + switch (t.kind) { + case "null": + return ["is_null(", ...expr, ")"]; + case "bool": + return ["is_bool(", ...expr, ")"]; + case "integer": + return ["is_int(", ...expr, ")"]; + case "double": + // PHP integers are acceptable wherever floats are. + return ["is_float(", ...expr, ") || is_int(", ...expr, ")"]; + case "string": + case "uuid": + return ["is_string(", ...expr, ")"]; + case "date-time": + return jsonSide + ? ["is_string(", ...expr, ")"] + : [...expr, " instanceof DateTime"]; + case "enum": + return jsonSide + ? ["is_string(", ...expr, ")"] + : [...expr, " instanceof ", this.nameForNamedType(t)]; + case "class": + return jsonSide + ? ["is_object(", ...expr, ")"] + : [...expr, " instanceof ", this.nameForNamedType(t)]; + case "map": + return jsonSide + ? ["is_object(", ...expr, ")"] + : [...expr, " instanceof stdClass"]; + case "array": + return ["is_array(", ...expr, ")"]; + case "any": + return null; + default: + throw new Error( + `PHP cannot check for union member type "${t.kind}"`, + ); + } + } + + // Emits an if/elseif chain over the union's members, dispatching on + // the runtime type of `expr`, with `emitNoMatch` as the else branch. + private emitUnionDispatch( + u: UnionType, + expr: Sourcelike[], + jsonSide: boolean, + emitMember: (t: Type) => void, + emitNoMatch: () => void, + ): void { + const { members, nullType } = this.sortedUnionMembers(u); + const all = nullType === null ? members : [nullType, ...members]; + let first = true; + let haveCatchAll = false; + for (const member of all) { + const check = this.unionMemberTypeCheck(member, expr, jsonSide); + if (check === null) { + if (first) { + emitMember(member); + return; + } + + this.emitLine("} else {"); + this.indent(() => emitMember(member)); + haveCatchAll = true; + break; + } + + this.emitLine(first ? "if (" : "} elseif (", ...check, ") {"); + first = false; + this.indent(() => emitMember(member)); + } + + if (!haveCatchAll) { + this.emitLine("} else {"); + this.indent(emitNoMatch); + } + + this.emitLine("}"); + } + protected phpType( _reference: boolean, t: Type, @@ -276,7 +456,7 @@ export class PhpRenderer extends ConvenienceRenderer { const nullable = nullableFromUnion(unionType); if (nullable !== null) return this.phpType(true, nullable, true, prefix, suffix); - return this.nameForNamedType(unionType); + return this.phpUnionType(unionType, false); }, (transformedStringType) => { if (transformedStringType.kind === "time") { @@ -305,10 +485,20 @@ export class PhpRenderer extends ConvenienceRenderer { (_integerType) => "int", (_doubleType) => "float", (_stringType) => "string", - (arrayType) => [ - this.phpDocConvertType(className, arrayType.items), - "[]", - ], + (arrayType) => { + const itemsDoc = this.phpDocConvertType( + className, + arrayType.items, + ); + if ( + arrayType.items instanceof UnionType && + nullableFromUnion(arrayType.items) === null + ) { + return ["(", itemsDoc, ")[]"]; + } + + return [itemsDoc, "[]"]; + }, (_classType) => _classType.getCombinedName(), (_mapType) => "stdClass", (enumType) => this.nameForNamedType(enumType), @@ -321,7 +511,7 @@ export class PhpRenderer extends ConvenienceRenderer { ]; } - throw new Error("union are not supported"); + return this.phpUnionType(unionType, false); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -356,7 +546,7 @@ export class PhpRenderer extends ConvenienceRenderer { return ["?", this.phpConvertType(className, nullable)]; } - throw new Error("union are not supported"); + return this.phpUnionType(unionType, true); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -435,7 +625,19 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error("union are not supported"); + this.emitUnionDispatch( + unionType, + args, + false, + (member) => + this.phpToObjConvert(className, member, lhs, args), + () => + this.emitLine( + "throw new Exception('Union value has no matching member in ", + className, + "');", + ), + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -546,7 +748,19 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error("union are not supported"); + this.emitUnionDispatch( + unionType, + args, + true, + (member) => + this.phpFromObjConvert(className, member, lhs, args), + () => + this.emitLine( + "throw new Exception('Cannot deserialize union value in ", + className, + "');", + ), + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -717,7 +931,16 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error(`union are not supported:${unionType}`); + // Any member's sample is a valid sample for the union. + const { members } = this.sortedUnionMembers(unionType); + this.phpSampleConvert( + className, + defined(members[0]), + lhs, + args, + idx, + suffix, + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -832,7 +1055,26 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error("not implemented"); + this.emitUnionDispatch( + unionType, + [scopeAttrName], + false, + (member) => + this.phpValidate( + className, + member, + attrName, + scopeAttrName, + ), + () => + this.emitLine( + 'throw new Exception("Attribute Error:', + className, + "::", + attrName, + '");', + ), + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { diff --git a/packages/quicktype-core/src/language/Php/utils.ts b/packages/quicktype-core/src/language/Php/utils.ts index 457b0dfe35..fc027ca5a9 100644 --- a/packages/quicktype-core/src/language/Php/utils.ts +++ b/packages/quicktype-core/src/language/Php/utils.ts @@ -55,3 +55,99 @@ export function phpNameStyle( isStartCharacter, ); } + +// Words that cannot be used as class names in PHP, in the PascalCase form +// the type namer produces (PHP reserves them case-insensitively): keywords, +// plus the reserved type and constant names, plus the classes the generated +// code itself refers to. Class names produced from JSON property names +// like "mixed" or "class" would otherwise fail to compile. +export const phpForbiddenClassNames: readonly string[] = [ + "Abstract", + "And", + "Array", + "As", + "Bool", + "Break", + "Callable", + "Case", + "Catch", + "Class", + "Clone", + "Const", + "Continue", + "Converter", + "DateTime", + "DateTimeInterface", + "Declare", + "Default", + "Die", + "Do", + "Echo", + "Else", + "Elseif", + "Empty", + "Enddeclare", + "Endfor", + "Endforeach", + "Endif", + "Endswitch", + "Endwhile", + "Enum", + "Eval", + "Exception", + "Exit", + "Extends", + "False", + "Final", + "Finally", + "Float", + "Fn", + "For", + "Foreach", + "Function", + "Global", + "Goto", + "If", + "Implements", + "Include", + "Instanceof", + "Insteadof", + "Int", + "Interface", + "Isset", + "Iterable", + "List", + "Match", + "Mixed", + "Namespace", + "Never", + "New", + "Null", + "Object", + "Or", + "Parent", + "Print", + "Private", + "Protected", + "Public", + "Readonly", + "Require", + "Return", + "Self", + "Static", + "StdClass", + "String", + "Switch", + "Throw", + "Trait", + "True", + "Try", + "Unset", + "Use", + "Var", + "Void", + "While", + "Xor", + "Yield", + "stdClass", +]; diff --git a/test/unit/php-unions.test.ts b/test/unit/php-unions.test.ts new file mode 100644 index 0000000000..483cde8f72 --- /dev/null +++ b/test/unit/php-unions.test.ts @@ -0,0 +1,67 @@ +// PHP generation used to fail with "union are not supported" whenever the +// input contained a union that wasn't just nullable — e.g. a heterogeneous +// array like [1, "two", {"nested": "object"}]. Unions are now rendered +// inline as PHP 8.0 union type declarations, with runtime type dispatch in +// the from/to/validate converters. +import { describe, expect, test } from "vitest"; + +import { + InputData, + jsonInputForTargetLanguage, + quicktype, +} from "quicktype-core"; + +async function phpForJSONSamples(samples: string[]): Promise { + const jsonInput = jsonInputForTargetLanguage("php"); + await jsonInput.addSource({ name: "TopLevel", samples }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + const result = await quicktype({ inputData, lang: "php" }); + return result.lines.join("\n"); +} + +describe("PHP union support", () => { + test("heterogeneous arrays render with runtime dispatch", async () => { + const php = await phpForJSONSamples([ + '{"mixed": [1, "two", true, null, {"nested": "object"}]}', + ]); + // Each member gets a runtime type check in the converters. + expect(php).toMatch(/is_int\(\$value\)/); + expect(php).toMatch(/is_string\(\$value\)/); + expect(php).toMatch(/is_bool\(\$value\)/); + expect(php).toMatch(/is_null\(\$value\)/); + expect(php).toMatch(/\$value instanceof \w/); + // Unmatched values fail loudly instead of being silently dropped. + expect(php).toMatch(/Cannot deserialize union value/); + }); + + test("a union property gets a PHP 8 union type declaration", async () => { + const php = await phpForJSONSamples([ + '{"v": 1}', + '{"v": "s"}', + '{"v": {"x": 1}}', + ]); + expect(php).toMatch(/private V\|int\|string \$v;/); + expect(php).toMatch(/stdClass\|int\|string \$value/); + }); + + test("nullable unions keep the ?-prefixed hint", async () => { + const php = await phpForJSONSamples(['{"a": "x"}', '{"a": null}']); + expect(php).toMatch(/private \?string \$a;/); + }); + + test("a double member accepts PHP integers", async () => { + // PHP has no union of both number types; 1 and 2.5 unify to + // double, whose runtime check must still match int values. + const php = await phpForJSONSamples(['{"n": [1, 2.5, "s"]}']); + expect(php).toMatch(/is_float\(\$value\) \|\| is_int\(\$value\)/); + }); + + test("reserved words are not used as class names", async () => { + // "mixed" is a reserved type name in PHP 8; `class Mixed` would + // fail to compile. + const php = await phpForJSONSamples(['{"mixed": {"a": 1}}']); + expect(php).not.toMatch(/class Mixed\b/); + expect(php).toMatch(/class MixedClass\b/); + }); +}); From 48c2ca118af46c7e5091af419b4b6437540d44ed Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 14 Jul 2026 15:13:38 -0400 Subject: [PATCH 2/4] fix(php): renderer bugs uncovered by enabling fixture tests Running the union-heavy JSON inputs and the JSON Schema fixture against the PHP renderer for the first time exposed several bugs in generated code: - `any`- and `null`-typed properties were declared as `Object` (and the converter parameters as the nonexistent types `any`/`null`), which fatals as soon as a null or scalar flows through. They are now `mixed`, matching the PHP 8.0 baseline the inline union declarations already require. - The `any` validation called `defined()`, which tests whether a *constant* of the given name exists and throws for non-string arguments. Any value is a valid `any`, so no check is emitted. - Double validation used a bare `is_float`, rejecting whole numbers, which json_decode hands over as ints. It now accepts ints, like the union member checks already did. - Nullable DateTime/UUID properties lost their `?` in type declarations (`optionalize` was not applied), so `fromX` fataled returning null. - The nullable branch of the `from` converters emitted a hardcoded `return null;`, which is wrong when the conversion is nested inside a map's foreach; it now uses the caller's lhs like the `to` side does. - Nested array samples closed with `);` in expression position, and map samples emitted statements in expression position; array closings now respect the caller's suffix and map samples are immediately-invoked closures. - Classes without properties generated a bodyless non-void `validate(): bool`, a TypeError when called; it now returns true. - A property named "this" produced an illegal `$this` constructor parameter; "this" is now a forbidden property name. - A union-member check for an enum on the JSON side only tested `is_string`, shadowing plain-string members (e.g. a UUID sharing a union with an enum); it now checks membership in the enum's values. Co-Authored-By: Claude Fable 5 --- .../src/language/Php/PhpRenderer.ts | 113 ++++++++++++++---- 1 file changed, 90 insertions(+), 23 deletions(-) diff --git a/packages/quicktype-core/src/language/Php/PhpRenderer.ts b/packages/quicktype-core/src/language/Php/PhpRenderer.ts index c8682f9199..85d4ea4ee0 100644 --- a/packages/quicktype-core/src/language/Php/PhpRenderer.ts +++ b/packages/quicktype-core/src/language/Php/PhpRenderer.ts @@ -74,7 +74,9 @@ export class PhpRenderer extends ConvenienceRenderer { _c: ClassType, _className: Name, ): ForbiddenWordsInfo { - return { names: [], includeGlobalForbidden: true }; + // `$this` is the only variable name PHP reserves; a property named + // "this" would produce an illegal `$this` constructor parameter. + return { names: ["this"], includeGlobalForbidden: true }; } protected makeNamedTypeNamer(): Namer { @@ -364,10 +366,30 @@ export class PhpRenderer extends ConvenienceRenderer { return jsonSide ? ["is_string(", ...expr, ")"] : [...expr, " instanceof DateTime"]; - case "enum": - return jsonSide - ? ["is_string(", ...expr, ")"] - : [...expr, " instanceof ", this.nameForNamedType(t)]; + case "enum": { + if (!jsonSide) { + return [...expr, " instanceof ", this.nameForNamedType(t)]; + } + + // On the JSON side an enum value is a string, but a plain + // string member may share the union, so check membership in + // the enum's values rather than just `is_string`. + const check: Sourcelike[] = [ + "is_string(", + ...expr, + ") && in_array(", + ...expr, + ", [", + ]; + let first = true; + for (const jsonName of (t as EnumType).cases) { + if (!first) check.push(", "); + check.push("'", stringEscape(jsonName), "'"); + first = false; + } + check.push("], true)"); + return check; + } case "class": return jsonSide ? ["is_object(", ...expr, ")"] @@ -441,9 +463,9 @@ export class PhpRenderer extends ConvenienceRenderer { return matchType( t, (_anyType) => - maybeAnnotated(isOptional, anyTypeIssueAnnotation, "Object"), + maybeAnnotated(isOptional, anyTypeIssueAnnotation, "mixed"), (_nullType) => - maybeAnnotated(isOptional, nullTypeIssueAnnotation, "Object"), + maybeAnnotated(isOptional, nullTypeIssueAnnotation, "mixed"), (_boolType) => optionalize("bool"), (_integerType) => optionalize("int"), (_doubleType) => optionalize("float"), @@ -468,10 +490,10 @@ export class PhpRenderer extends ConvenienceRenderer { } if (transformedStringType.kind === "date-time") { - return "DateTime"; + return optionalize("DateTime"); } - return "string"; + return optionalize("string"); }, ); } @@ -530,8 +552,8 @@ export class PhpRenderer extends ConvenienceRenderer { protected phpConvertType(className: Name, t: Type): Sourcelike { return matchType( t, - (_anyType) => "any", - (_nullType) => "null", + (_anyType) => "mixed", + (_nullType) => "mixed", (_boolType) => "bool", (_integerType) => "int", (_doubleType) => "float", @@ -743,7 +765,7 @@ export class PhpRenderer extends ConvenienceRenderer { this.phpFromObjConvert(className, nullable, lhs, args), ); this.emitLine("} else {"); - this.indent(() => this.emitLine("return null;")); + this.indent(() => this.emitLine(...lhs, " null;")); this.emitLine("}"); return; } @@ -883,7 +905,7 @@ export class PhpRenderer extends ConvenienceRenderer { "", ); }); - this.emitLine("); /* ", `${idx}`, ":", args, "*/"); + this.emitLine(")", suffix, " /* ", `${idx}`, ":", args, "*/"); }, (classType) => this.emitLine( @@ -898,16 +920,30 @@ export class PhpRenderer extends ConvenienceRenderer { "*/", ), (mapType) => { - this.emitLine("$out = new stdClass();"); - this.phpSampleConvert( - className, - mapType.values, - ["$out->{'", className, "'} = "], + // An immediately-invoked closure, so the sample is an + // expression and can nest inside arrays and other maps. + this.emitLine(...lhs, " (function () {"); + this.indent(() => { + this.emitLine("$out = new stdClass();"); + this.phpSampleConvert( + className, + mapType.values, + ["$out->{'", className, "'} = "], + args, + idx, + ";", + ); + this.emitLine("return $out;"); + }); + this.emitLine( + "})()", + suffix, + " /* ", + `${idx}`, + ":", args, - idx, - ";", + "*/", ); - this.emitLine("return $out;"); }, (enumType) => this.emitLine( @@ -995,11 +1031,36 @@ export class PhpRenderer extends ConvenienceRenderer { matchType( t, - (_anyType) => is("defined"), + (_anyType) => { + // Every value is a valid `any`; there is nothing to check. + // (This used to call `defined()`, which tests whether a + // *constant* of the given name exists and throws for + // non-string arguments.) + }, (_nullType) => is("is_null"), (_boolType) => is("is_bool"), (_integerType) => is("is_integer"), - (_doubleType) => is("is_float"), + (_doubleType) => { + // PHP integers are acceptable wherever floats are, and + // json_decode gives an int for a whole JSON number. + this.emitBlock( + [ + "if (!is_float(", + scopeAttrName, + ") && !is_int(", + scopeAttrName, + "))", + ], + () => + this.emitLine( + 'throw new Exception("Attribute Error:', + className, + "::", + attrName, + '");', + ), + ); + }, (_stringType) => is("is_string"), (arrayType) => { is("is_array"); @@ -1462,6 +1523,12 @@ export class PhpRenderer extends ConvenienceRenderer { p = "|| "; }, ); + if (lines.length === 0) { + // A class without properties has nothing to check. + this.emitLine("return true;"); + return; + } + lines.forEach((line, jdx) => { this.emitLine( ...line, From 8e845c900a95be01091978cb9ab3ce18490a538d Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 14 Jul 2026 15:13:53 -0400 Subject: [PATCH 3/4] test(php): exercise non-nullable unions through the fixture system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PHP fixture previously ran only a whitelist of easy JSON samples and no schema fixture at all, so the new union support was covered by unit tests alone. quicktype's primary testing method is the end-to-end JSON / JSON Schema fixture tests, so: - Enable the union-heavy JSON inputs for PHP: unions.json, union-constructor-clash.json, combinations1-4.json, nst-test-suite.json, kitchen-sink.json, list.json and bug427.json. - Add test/inputs/json/priority/php-mixed-union.json, the motivating repro: a heterogeneous array (int, string, bool, null and an object) under a property named "mixed", which is a PHP reserved word, so it exercises the reserved-word class renaming and the union runtime dispatch together. The input is skipped for the languages that already skip unions.json (Ruby, Scala 3, Smithy4s, Kotlin and Kotlin Jackson), since it is a subset of that input's shapes. - Register the JSON Schema fixture for PHP and run it in CI as schema-php. 61 of the 65 schemas run; the four skips are keyword-unions.schema (PHP class names are case-insensitive but the namer dedups case-sensitively — the same reason Java and Python skip it), recursive-union-flattening.schema (a top-level union produces no named TopLevel class now that unions are inlined), top-level-enum.schema (generated top-level enum code is incompatible with the driver, as for C# and Ruby) and union.schema (the driver does not support top-level arrays). Not enabled: optional-union.json and the identifiers/keywords inputs. Top-level arrays are unsupported by the PHP driver, identifiers.json needs comment/string escaping of raw JSON names, and keywords.json hits the case-insensitive class-name collision above — all pre-existing limitations independent of union support. Co-Authored-By: Claude Fable 5 --- .github/workflows/test-pr.yaml | 2 +- test/fixtures.ts | 1 + .../inputs/json/priority/php-mixed-union.json | 3 ++ test/languages.ts | 35 ++++++++++++++++++- 4 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 test/inputs/json/priority/php-mixed-union.json diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 41750129c2..e1e30b95a5 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -50,7 +50,7 @@ jobs: # - swift,schema-swift # pgp issue - javascript-prop-types - ruby - - php + - php,schema-php - scala3,schema-scala3 - elixir,schema-elixir,graphql-elixir - comment-injection-treesitter,comment-injection-typescript,comment-injection-typescript-zod,comment-injection-typescript-effect-schema diff --git a/test/fixtures.ts b/test/fixtures.ts index 33f9dd1c29..d9f78eaea2 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1589,6 +1589,7 @@ export const allFixtures: Fixture[] = [ new JSONSchemaFixture(languages.RustLanguage), new JSONSchemaFixture(languages.RubyLanguage), new JSONSchemaFixture(languages.PythonLanguage), + new JSONSchemaFixture(languages.PHPLanguage), new JSONSchemaFixture(languages.ElmLanguage), new JSONSchemaFixture(languages.SwiftLanguage), new JSONSchemaFixture(languages.TypeScriptLanguage), diff --git a/test/inputs/json/priority/php-mixed-union.json b/test/inputs/json/priority/php-mixed-union.json new file mode 100644 index 0000000000..ed7ec4427b --- /dev/null +++ b/test/inputs/json/priority/php-mixed-union.json @@ -0,0 +1,3 @@ +{ + "mixed": [1, "two", true, null, {"nested": "object"}] +} diff --git a/test/languages.ts b/test/languages.ts index b064f74c8d..73da3248fb 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -427,6 +427,7 @@ export const RubyLanguage: Language = { "optional-union.json", "union-constructor-clash.json", "unions.json", + "php-mixed-union.json", "nbl-stats.json", "kitchen-sink.json", ], @@ -995,6 +996,7 @@ I havea no idea how to encode these tests correctly. "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", // Scala3 has the same prelude-shadowing bug that this input @@ -1079,6 +1081,7 @@ I havea no idea how to encode these tests correctly. "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", ], skipSchema: [], @@ -1131,6 +1134,7 @@ export const KotlinLanguage: Language = { "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", // Klaxon does not support top-level primitives "no-classes.json", @@ -1218,6 +1222,7 @@ export const KotlinJacksonLanguage: Language = { "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", // Klaxon does not support top-level primitives "no-classes.json", @@ -1474,9 +1479,37 @@ export const PHPLanguage: Language = { "uuids.json", "nested-objects.json", "bug2663.json", + // Union-heavy inputs: PHP renders non-nullable unions as inline + // PHP 8.0 union type declarations with runtime dispatch. + "unions.json", + "union-constructor-clash.json", + "combinations1.json", + "combinations2.json", + "combinations3.json", + "combinations4.json", + "nst-test-suite.json", + "kitchen-sink.json", + "list.json", + "bug427.json", + // The motivating repro for non-nullable union support: a + // heterogeneous array under a PHP-reserved-word property name. + "php-mixed-union.json", ], skipMiscJSON: true, - skipSchema: [], + skipSchema: [ + // PHP class names are case-insensitive, but the namer dedups + // case-sensitively, so this declares classes that collide (same + // reason Java and Python skip it). + "keyword-unions.schema", + // Unions are inlined as PHP union type declarations, so a + // top-level union produces no named TopLevel class for the driver. + "recursive-union-flattening.schema", + // The generated code for top-level enums is incompatible with the + // driver. + "top-level-enum.schema", + // The driver does not support top-level arrays. + "union.schema", + ], rendererOptions: {}, quickTestRendererOptions: [], sourceFiles: ["src/language/Php/index.ts"], From f64bef5e4a2b53bdbcc4acaaaf092076c99bf41b Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 14 Jul 2026 15:14:04 -0400 Subject: [PATCH 4/4] docs: state the fixture-first testing principle in CLAUDE.md Any change affecting generated output must be covered by the JSON / JSON Schema fixture tests; unit tests complement them but are never a substitute. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3a1b7298d4..938400de68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,32 @@ # Repository conventions +## Testing + +quicktype's primary testing method is end-to-end fixture tests driven by JSON +and JSON Schema files. For each sample input, the fixture generates code for a +language, runs a driver program in `test/fixtures//` that +deserializes the sample and serializes it back, and compares the round-tripped +JSON to the input. + +- JSON inputs live in `test/inputs/json/`: `priority/` and `samples/` always + run, `misc/` is skipped under `QUICKTEST` (and for languages with + `skipMiscJSON`). +- JSON Schema inputs live in `test/inputs/schema/`: each `*.schema` comes with + `.N.json` samples and `.N.fail..json` expected-failure samples. A + fail sample must make the generated program exit nonzero; which fail + samples run is controlled by the language's `features` list. +- Per-language configuration — which inputs run (`skipJSON`, `includeJSON`, + `skipSchema`), renderer options, and `features` — lives in + `test/languages.ts`; fixtures are registered in `test/fixtures.ts`. +- Run one language's fixtures with `FIXTURE= script/test`, for example + `FIXTURE=php script/test` or `FIXTURE=schema-php script/test`. + +Any change that affects generated output MUST be covered by a JSON or JSON +Schema fixture test — by enabling existing inputs for the language or adding +new ones. Unit tests in `test/unit/` are a complement for what fixtures cannot +express (asserting that some code is *not* generated, API-level behavior, fast +local iteration) — never a substitute. + ## Releasing / version bumps Do not bump versions in any `package.json` before a release. Package manifest