Strict Output Validator — PHP source
Check a JSON Schema against OpenAI structured-outputs strict mode rules — open objects, missing required keys, unsupported keywords — before the API rejects it. 100% client-side.
This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* Strict Output Validator — check a JSON Schema against OpenAI structured-
* outputs strict-mode rules, so it fails here instead of at the API.
* Port of src/lib/strictOutputValidator.ts
*
* Language: PHP (8.1+, standard library only)
* Source: CosmoDev polyglot showcase port of the Strict Output Validator
* tool (slug: strict-output-validator), ported from
* src/lib/strictOutputValidator.ts (the canonical TypeScript
* implementation). javascript.js in this set carries the same
* port; this file mirrors it for PHP.
* Tool page: https://dev.cosmolabs.org/tools/strict-output-validator
*
* Rules (2026 OpenAI strict mode):
* R1 root must be type "object" (validateStrictRoot)
* R2 every object node needs additionalProperties: false
* R3 every key in properties must be listed in required (no optional keys)
* R4 required must not name keys absent from properties
* R5 only the supported type values / keywords may appear
* The keyword allowlist is conservative: keywords OpenAI documents as
* unsupported are flagged so the verdict is actionable, not just binary.
*/
declare(strict_types=1);
namespace CosmoDev\StrictOutputValidator;
/** Types strict mode supports. */
const SUPPORTED_TYPES = [
'object',
'array',
'string',
'number',
'integer',
'boolean',
];
/** Keywords strict mode understands per-node. Everything else is flagged.
* ("allOf" is accepted only as single-element; checked in the walker.) */
const SUPPORTED_KEYWORDS = [
'type',
'description',
'title',
'properties',
'required',
'additionalProperties',
'items',
'enum',
'const',
'anyOf',
'allOf',
'$ref',
'$defs',
'definitions',
'format',
'nullable',
'default',
];
/**
* One rule violation — the PHP spelling of the TypeScript StrictIssue.
* Arrays are keyed the same way: path, rule, message.
*/
function makeIssue(string $path, string $rule, string $message): array
{
return ['path' => $path, 'rule' => $rule, 'message' => $message];
}
/** True for JSON objects (assoc arrays) — not lists, not scalars, not null. */
function isObj(mixed $value): bool
{
return is_array($value);
}
/**
* Validate a schema against the strict-mode structural rules. Accepts either
* a pre-parsed schema (an assoc array, as json_decode(..., true) produces)
* or a JSON string (parsed here; a parse failure is reported as a single
* `invalid-schema` issue). R1 (root must be an object type) is NOT checked
* here — use validateStrictRoot() for that.
*
* @param mixed $input JSON text or an already-decoded schema
* @return array{ok: bool, issues: list<array{path: string, rule: string, message: string}>, counts: array{objects: int, properties: int, enums: int}}
*/
function validateStrictSchema(mixed $input): array
{
$issues = [];
$counts = ['objects' => 0, 'properties' => 0, 'enums' => 0];
$schema = $input;
if (is_string($input)) {
$decoded = json_decode($input, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [
'ok' => false,
'issues' => [
makeIssue('$', 'invalid-schema', 'Not valid JSON: ' . json_last_error_msg()),
],
'counts' => $counts,
];
}
$schema = $decoded;
}
if (!isObj($schema)) {
return [
'ok' => false,
'issues' => [makeIssue('$', 'invalid-schema', 'Schema must be a JSON object.')],
'counts' => $counts,
];
}
walk($schema, '$', $issues, $counts);
return ['ok' => count($issues) === 0, 'issues' => $issues, 'counts' => $counts];
}
/** Flag every keyword strict mode does not understand. */
function unsupportedKeywords(array $node, string $path, array &$issues): void
{
foreach (array_keys($node) as $key) {
if (!in_array($key, SUPPORTED_KEYWORDS, true)) {
$issues[] = makeIssue(
$path,
'unsupported-keyword',
"\"{$key}\" is not supported in strict mode — remove it or express the constraint another way.",
);
}
}
}
/** Depth-first walk emitting issues and accumulating counts. */
function walk(array $node, string $path, array &$issues, array &$counts): void
{
unsupportedKeywords($node, $path, $issues);
$type = $node['type'] ?? null;
// 'null' is only expressible inside a type array (the nullable form).
$isNullableForm = is_array($type);
$typeList = [];
if ($isNullableForm) {
$typeList = $type;
} elseif (is_string($type)) {
$typeList = [$type];
}
foreach ($typeList as $t) {
$supported = is_string($t)
&& (in_array($t, SUPPORTED_TYPES, true) || ($isNullableForm && $t === 'null'));
if (!$supported) {
$issues[] = makeIssue(
$path,
'unsupported-type',
'type ' . json_encode($t)
. ' is not supported — strict mode allows object, array, string, number, integer, boolean (null only inside a type array).',
);
}
}
// allOf is accepted only as a single-element wrapper.
$allOf = $node['allOf'] ?? null;
if (is_array($allOf) && count($allOf) !== 1) {
$issues[] = makeIssue(
$path,
'unsupported-keyword',
'allOf is supported only with exactly one subschema (use anyOf for unions).',
);
}
if (
($node['type'] ?? null) === 'object'
|| array_key_exists('properties', $node)
|| array_key_exists('required', $node)
) {
$counts['objects']++;
if (($node['additionalProperties'] ?? null) !== false) {
$issues[] = makeIssue(
$path,
'missing-additional-properties',
'Object needs "additionalProperties": false — strict mode rejects open objects.',
);
}
$props = isset($node['properties']) && isObj($node['properties']) ? $node['properties'] : [];
$required = isset($node['required']) && is_array($node['required']) ? $node['required'] : [];
$counts['properties'] += count($props);
foreach (array_keys($props) as $key) {
if (!in_array($key, $required, true)) {
$issues[] = makeIssue(
"{$path}.required",
'property-not-required',
"\"{$key}\" is defined in properties but missing from required — strict mode requires every property.",
);
}
}
foreach ($required as $key) {
if (is_string($key) && !array_key_exists($key, $props)) {
$issues[] = makeIssue(
"{$path}.required",
'required-not-property',
"\"{$key}\" is required but has no definition in properties.",
);
}
}
foreach ($props as $key => $sub) {
if (isObj($sub)) {
walk($sub, "{$path}.properties.{$key}", $issues, $counts);
}
}
}
$items = $node['items'] ?? null;
if (isObj($items)) {
walk($items, "{$path}.items", $issues, $counts);
}
if (isset($node['enum']) && is_array($node['enum'])) {
$counts['enums']++;
}
foreach (['anyOf', 'oneOf', 'allOf'] as $listKey) {
$list = $node[$listKey] ?? null;
if (is_array($list)) {
if ($listKey === 'oneOf') {
$issues[] = makeIssue(
"{$path}.{$listKey}",
'unsupported-keyword',
'oneOf is not supported — strict mode unions are expressed with anyOf.',
);
}
foreach ($list as $i => $sub) {
if (isObj($sub)) {
walk($sub, "{$path}.{$listKey}[{$i}]", $issues, $counts);
}
}
}
}
foreach (['$defs', 'definitions'] as $defsKey) {
$defs = $node[$defsKey] ?? null;
if (isObj($defs)) {
foreach ($defs as $name => $sub) {
if (isObj($sub)) {
walk($sub, "{$path}.{$defsKey}.{$name}", $issues, $counts);
}
}
}
}
}
/**
* Whole-report entry point: everything validateStrictSchema() checks, plus
* R1 — the root schema must be type "object" (strict mode cannot return a
* bare scalar or array). The root issue is unshifted to the front.
*
* @param mixed $input JSON text or an already-decoded schema
* @return array{ok: bool, issues: list<array{path: string, rule: string, message: string}>, counts: array{objects: int, properties: int, enums: int}}
*/
function validateStrictRoot(mixed $input): array
{
$report = validateStrictSchema($input);
$schema = $input;
if (is_string($input)) {
$decoded = json_decode($input, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return $report; // invalid-schema already reported
}
$schema = $decoded;
}
if (isObj($schema) && ($schema['type'] ?? null) !== 'object') {
array_unshift(
$report['issues'],
makeIssue(
'$',
'root-not-object',
'The root schema must be type "object" — strict mode cannot return a bare scalar or array.',
),
);
$report['ok'] = false;
}
return $report;
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →