JSON to Zod Schema — PHP source
Generate Zod validation schemas from JSON. Infers z.string, z.number, z.boolean, z.object, z.array, z.null, and z.union for mixed arrays.
This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* json-to-zod — PHP polyglot showcase port.
*
* Recursively infers a Zod schema string from a JSON value. Mixed-type arrays
* collapse to z.union(...); plain objects become z.object({...}); empty arrays
* and objects fall back to z.array(z.unknown()) / z.object({}). The function
* never throws — JSON parse failures and inference problems are returned as
* ['ok' => false, 'error' => ...].
*
* Ported from src/lib/jsonToZod.ts (CosmoDev).
* Display source — part of CosmoDev's polyglot tool pages (dev.cosmolabs.org).
*/
declare(strict_types=1);
/**
* Turn an arbitrary string into a valid JS identifier:
* - drop every char outside [A-Za-z0-9_$]
* - replace each leading digit with '_' (identifiers can't start with a digit)
* - default to "schema" when nothing usable remains.
*/
function sanitizeVarName(string $name): string {
$cleaned = preg_replace('/[^A-Za-z0-9_$]/', '', $name);
$cleaned = preg_replace_callback('/^[0-9]+/', fn(array $m) => str_repeat('_', strlen($m[0])), $cleaned);
return $cleaned === '' ? 'schema' : $cleaned;
}
/**
* Indent every non-empty line of $s by $depth spaces. Blank lines are left
* untouched so they don't pick up trailing whitespace.
*/
function pad(string $s, int $depth): string {
$prefix = str_repeat(' ', $depth);
$lines = explode("\n", $s);
foreach ($lines as &$line) {
if ($line !== '') {
$line = $prefix . $line;
}
}
unset($line);
return implode("\n", $lines);
}
/**
* Order-preserving de-duplication, mirroring JS [...new Set(seq)]. array_unique
* alone would reorder numeric-looking strings under SORT_REGULAR; SORT_STRING
* keeps string equality semantics intact.
*/
function distinctStrings(array $seq): array {
$seen = [];
$out = [];
foreach ($seq as $item) {
if (!in_array($item, $seen, true)) {
$seen[] = $item;
$out[] = $item;
}
}
return $out;
}
/**
* Infer a Zod schema string for $value at the given indentation depth.
*
* Note: JSON is decoded with assoc=false (objects as stdClass) so that empty
* objects ({}) and empty arrays ([]) stay distinguishable — both decode to an
* empty PHP array in assoc mode.
*/
function inferZod(mixed $value, int $indent = 2): string {
if ($value === null) {
return 'z.null()';
}
if (is_bool($value)) {
return 'z.boolean()';
}
if (is_int($value) || is_float($value)) {
return 'z.number()';
}
if (is_string($value)) {
return 'z.string()';
}
if (is_array($value)) {
// JSON arrays decode to PHP list-style arrays (sequential int keys).
if (count($value) === 0) {
return 'z.array(z.unknown())';
}
$types = array_map(fn($e) => inferZod($e, $indent + 2), array_values($value));
$distinct = distinctStrings($types);
// Single shared element type → z.array(T). Multiple → z.union([...]).
// The union renders the *full* $types list (duplicates included) to
// match the TypeScript reference byte-for-byte.
if (count($distinct) === 1) {
$inner = $distinct[0];
} else {
$inner = "z.union([\n"
. pad(implode(",\n", $types), $indent + 2)
. "\n"
. pad('', $indent)
. '])';
}
return "z.array({$inner})";
}
if ($value instanceof \stdClass) {
// get_object_vars preserves source insertion order in modern PHP.
$entries = get_object_vars($value);
if (count($entries) === 0) {
return 'z.object({})';
}
$pad0 = str_repeat(' ', $indent);
$pad1 = str_repeat(' ', $indent + 2);
$fields = [];
foreach ($entries as $key => $child) {
$fields[] = "{$pad1}{$key}: " . inferZod($child, $indent + 2) . ',';
}
return "z.object({\n" . implode("\n", $fields) . "\n{$pad0}})";
}
// Unreachable for valid JSON; deterministic safety net.
return 'z.unknown()';
}
/**
* Convert a JSON string into a `const NAME = <zod schema>;` declaration.
*
* @return array{ok: bool, code: string, error: ?string}
*/
function jsonToZod(string $jsonString, ?string $rootName = null): array {
// assoc=false keeps {} (stdClass) and [] (array) distinct, see inferZod.
$value = json_decode($jsonString, false);
if (json_last_error() !== JSON_ERROR_NONE) {
// json_decode is fallible rather than exception-throwing: capture the
// parse error message before any later call clears it.
return ['ok' => false, 'code' => '', 'error' => json_last_error_msg()];
}
try {
$name = sanitizeVarName($rootName ?? 'Root');
return [
'ok' => true,
'code' => "const {$name} = " . inferZod($value, 2) . ';',
'error' => null,
];
} catch (\Throwable $e) {
return ['ok' => false, 'code' => '', 'error' => $e->getMessage()];
}
}
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 →