Skip to content

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 →