Skip to content

Instantly share code, notes, and snippets.

@daryledesilva
Created January 23, 2026 10:25
Show Gist options
  • Select an option

  • Save daryledesilva/b32957aa8cc47a2b4523f74bdcde9e71 to your computer and use it in GitHub Desktop.

Select an option

Save daryledesilva/b32957aa8cc47a2b4523f74bdcde9e71 to your computer and use it in GitHub Desktop.
JSON-to-DTO Generator for Laravel + JMS Serializer - Generate PHP DTOs from JSON API fixtures

JSON-to-DTO Generator for Laravel + JMS Serializer

Generate PHP DTOs with JMS Serializer annotations from JSON API response fixtures.

Features

  • Schema inference from multiple JSON samples per endpoint
  • Nullability detection:
    • Field missing in some responses → ?Type $field = null
    • Field always present but sometimes null → ?Type $field (no default)
    • Field always present, never null → Type $field
  • Nested object handling: Generates separate Model classes
  • Array support: array<Type>, array<array<Type>>, array<string, Type>
  • Dynamic map detection: Keys with special chars or >30 chars become array<string, Type>
  • Class merging: Same-named classes across endpoints are merged
  • Automatic singularization: productsProduct, sailingsSailing

Installation

  1. Copy GenerateJmsDtosCommand.php to app/Console/Commands/
  2. Update the namespace if needed
  3. Register in app/Console/Kernel.php (Laravel auto-discovers if in the right folder)

Usage

Step 1: Capture JSON Fixtures

Add the fixture dumper middleware to your HTTP client temporarily:

// In your ServiceProvider or wherever you create the HTTP client
/** @var \GuzzleHttp\HandlerStack $handler */
$handler = $httpClient->getConfig('handler');
$handler->push(function (callable $next) {
    return function ($request, array $options) use ($next) {
        return $next($request, $options)->then(function ($response) use ($request) {
            $path = trim($request->getUri()->getPath(), '/');
            $dir = base_path('temp/' . $path);
            if (!is_dir($dir)) {
                mkdir($dir, 0755, true);
            }
            $body = (string) $request->getBody();
            $filename = $body ? md5($body) : 'get';
            $json = json_decode((string) $response->getBody(), true);
            file_put_contents($dir . '/' . $filename . '.json', json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
            $response->getBody()->rewind();
            return $response;
        });
    };
}, 'fixture_dumper');

Then make API requests to generate fixtures:

php artisan your:command-that-hits-api

Step 2: Analyze Schema (Optional)

Preview the detected schema without generating files:

php artisan dto:generate temp/api-fixtures --analyze

Output:

============================================================
Endpoint: /users
Files: 3
============================================================
├── id: int
├── name: string
├── email: ?string
├── profile: object
│   ├── avatar: string
│   └── bio: ?string
└── roles: array<object>
    ├── id: int
    └── name: string

Step 3: Generate DTOs

php artisan dto:generate temp/api-fixtures \
  --output=app/SDK/MyApi \
  --namespace="App\SDK\MyApi"

Output structure:

app/SDK/MyApi/
├── Response/
│   ├── ApiResponse.php      # Base class with JMS deserialization
│   └── UsersResponse.php    # Per-endpoint response DTO
└── Model/
    ├── Profile.php          # Nested objects
    └── Role.php             # Array items (singularized)

Step 4: Use Generated DTOs

use App\SDK\MyApi\Response\UsersResponse;

$response = Http::get('https://api.example.com/users');
$dto = UsersResponse::parse($response);

foreach ($dto->users as $user) {
    echo $user->name;
    echo $user->profile->bio;
}

Nullability Rules

Scenario PHP Type Default Value
Field missing in some responses ?Type = null
Field always present, sometimes explicit null ?Type none (fails if missing)
Field always present, never null Type none

Requirements

  • PHP 8.1+
  • Laravel 8+
  • jms/serializer package

Tips

  1. Capture multiple variations - The more JSON samples, the better the nullability detection
  2. Check generated code - Review for edge cases the analyzer might miss
  3. Remove middleware after - Don't forget to remove the fixture dumper before committing
  4. Add to .gitignore - Add temp/ to your .gitignore

License

MIT

<?php
/**
* Guzzle Middleware: JSON Fixture Dumper
*
* Intercepts HTTP responses and dumps JSON to temp files for later analysis.
* Use this to capture API responses for the DTO generator.
*
* Usage:
* 1. Get the Guzzle handler stack from your HTTP client
* 2. Push this middleware onto the stack
* 3. Make API requests - JSON will be dumped to temp/{url-path}/
* 4. Run the DTO generator on the dumped fixtures
*
* @see GenerateJmsDtosCommand.php
*/
// Example: Adding to a Guzzle client
//
// /** @var \GuzzleHttp\HandlerStack $handler */
// $handler = $httpClient->getConfig('handler');
// $handler->push(createFixtureDumperMiddleware(), 'fixture_dumper');
/**
* Create the fixture dumper middleware.
*
* @param string $basePath Base path for dumped fixtures (default: base_path('temp'))
* @return callable
*/
function createFixtureDumperMiddleware(string $basePath = null): callable
{
$basePath = $basePath ?? base_path('temp');
return function (callable $next) use ($basePath) {
return function ($request, array $options) use ($next, $basePath) {
return $next($request, $options)->then(function ($response) use ($request, $basePath) {
// Get URL path for folder structure
$path = trim($request->getUri()->getPath(), '/');
$dir = $basePath . '/' . $path;
// Create directory if needed
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
// Use MD5 of request body as filename (or 'get' for GET requests)
$body = (string) $request->getBody();
$filename = $body ? md5($body) : 'get';
// Decode and pretty-print JSON
$json = json_decode((string) $response->getBody(), true);
file_put_contents(
$dir . '/' . $filename . '.json',
json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
// Rewind response body so it can be read again
$response->getBody()->rewind();
return $response;
});
};
};
}
// =============================================================================
// Alternative: Inline version for quick copy-paste
// =============================================================================
//
// $handler->push(function (callable $next) {
// return function ($request, array $options) use ($next) {
// return $next($request, $options)->then(function ($response) use ($request) {
// $path = trim($request->getUri()->getPath(), '/');
// $dir = base_path('temp/' . $path);
// if (!is_dir($dir)) {
// mkdir($dir, 0755, true);
// }
// $body = (string) $request->getBody();
// $filename = $body ? md5($body) : 'get';
// $json = json_decode((string) $response->getBody(), true);
// file_put_contents($dir . '/' . $filename . '.json', json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// $response->getBody()->rewind();
// return $response;
// });
// };
// }, 'fixture_dumper');
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* JSON-to-DTO Generator Command
*
* Analyzes JSON API response fixtures and generates JMS Serializer-compatible PHP DTOs.
*
* Features:
* - Detects types, nullability, nested objects, arrays, and dynamic maps
* - Merges same-named classes across endpoints
* - Generates proper #[Type] annotations for JMS Serializer
* - Creates Response DTOs (extend ApiResponse) and Model DTOs
*
* @see https://gist.github.com/your-username/your-gist-id
*/
class GenerateJmsDtosCommand extends Command
{
protected $signature = 'dto:generate
{path : Path to JSON fixtures directory (relative to base_path)}
{--output= : Output directory for generated DTOs (required)}
{--namespace= : Base namespace for generated DTOs (required)}
{--analyze : Only analyze and print schema, do not generate DTOs}';
protected $description = 'Analyze JSON fixtures and generate JMS Serializer-compatible DTOs';
private string $outputDir;
private string $baseNamespace;
// Global class registry: className => [properties, totalOccurrences, isResponse]
private array $classRegistry = [];
private int $totalFilesAcrossAllEndpoints = 0;
public function handle(): int
{
$basePath = base_path($this->argument('path'));
if (!is_dir($basePath)) {
$this->error("Directory not found: $basePath");
return 1;
}
$analyzeOnly = $this->option('analyze');
if (!$analyzeOnly) {
$this->outputDir = $this->option('output');
$this->baseNamespace = $this->option('namespace');
if (!$this->outputDir || !$this->baseNamespace) {
$this->error('Both --output and --namespace are required unless using --analyze');
$this->line('');
$this->line('Example usage:');
$this->line(' php artisan dto:generate temp/api-fixtures --analyze');
$this->line(' php artisan dto:generate temp/api-fixtures --output=app/SDK/MyApi --namespace="App\SDK\MyApi"');
return 1;
}
$this->outputDir = base_path($this->outputDir);
}
$endpoints = $this->findEndpoints($basePath);
if (empty($endpoints)) {
$this->error("No JSON files found in: $basePath");
return 1;
}
// Count total files across all endpoints
foreach ($endpoints as $jsonFiles) {
$this->totalFilesAcrossAllEndpoints += count($jsonFiles);
}
$this->info("Found " . count($endpoints) . " endpoint(s) with {$this->totalFilesAcrossAllEndpoints} JSON file(s)");
foreach ($endpoints as $endpoint => $jsonFiles) {
$this->info("\n" . str_repeat('=', 60));
$this->info("Endpoint: /$endpoint");
$this->info("Files: " . count($jsonFiles));
$this->info(str_repeat('=', 60));
$schema = $this->buildSchemaFromFiles($jsonFiles);
if ($analyzeOnly) {
$this->printSchema($schema, 0, count($jsonFiles));
} else {
$className = $this->endpointToClassName($endpoint);
$this->registerClass($className, $schema, count($jsonFiles), true);
}
}
if (!$analyzeOnly) {
$this->writeAllClasses();
}
return 0;
}
// =========================================================================
// Endpoint Discovery
// =========================================================================
private function findEndpoints(string $basePath): array
{
$endpoints = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($basePath, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'json') {
$dir = dirname($file->getPathname());
$endpoint = str_replace($basePath . '/', '', $dir);
if (!isset($endpoints[$endpoint])) {
$endpoints[$endpoint] = [];
}
$endpoints[$endpoint][] = $file->getPathname();
}
}
ksort($endpoints);
return $endpoints;
}
// =========================================================================
// Schema Building
// =========================================================================
private function buildSchemaFromFiles(array $jsonFiles): array
{
$mergedSchema = [];
$totalFiles = count($jsonFiles);
foreach ($jsonFiles as $file) {
$content = json_decode(file_get_contents($file), true);
if ($content === null) {
continue;
}
$this->mergeSchema($mergedSchema, $content, $totalFiles);
}
return $mergedSchema;
}
private function mergeSchema(array &$schema, mixed $data, int $totalFiles): void
{
if (!is_array($data) || !$this->isAssoc($data)) {
return;
}
foreach ($data as $key => $value) {
if (is_numeric($key)) {
continue;
}
if (!isset($schema[$key])) {
$schema[$key] = [
'types' => [],
'count' => 0,
'hasNull' => false,
'children' => [],
'isDynamicMap' => false,
'nestedChildren' => [],
'totalFiles' => $totalFiles,
];
}
$schema[$key]['count']++;
if ($value === null) {
$schema[$key]['hasNull'] = true;
$schema[$key]['types']['null'] = true;
continue;
}
$type = $this->getBaseType($value);
$schema[$key]['types'][$type] = true;
if (is_array($value)) {
if ($this->isAssoc($value)) {
if ($this->isDynamicMap($value)) {
$schema[$key]['isDynamicMap'] = true;
foreach ($value as $mapValue) {
if (is_array($mapValue) && $this->isAssoc($mapValue)) {
$this->mergeSchema($schema[$key]['children'], $mapValue, $totalFiles);
}
}
} else {
$this->mergeSchema($schema[$key]['children'], $value, $totalFiles);
}
} elseif (!empty($value)) {
$this->analyzeArrayItems($schema[$key], $value, $totalFiles);
}
}
}
}
private function analyzeArrayItems(array &$fieldSchema, array $items, int $totalFiles): void
{
$itemTypes = [];
foreach ($items as $item) {
$itemType = $this->getBaseType($item);
$itemTypes[$itemType] = true;
if (is_array($item)) {
if ($this->isAssoc($item)) {
$this->mergeSchema($fieldSchema['children'], $item, $totalFiles);
} elseif (!empty($item)) {
$this->analyzeNestedArray($fieldSchema, $item, $totalFiles);
}
}
}
unset($fieldSchema['types']['array']);
if (!empty($itemTypes)) {
$typeStr = 'array<' . implode('|', array_keys($itemTypes)) . '>';
$fieldSchema['types'][$typeStr] = true;
} else {
$fieldSchema['types']['array<unknown>'] = true;
}
}
private function analyzeNestedArray(array &$fieldSchema, array $items, int $totalFiles): void
{
foreach ($items as $item) {
if (is_array($item) && $this->isAssoc($item)) {
$this->mergeSchema($fieldSchema['nestedChildren'], $item, $totalFiles);
}
}
}
private function isDynamicMap(array $value): bool
{
if (empty($value)) {
return false;
}
$keys = array_keys($value);
$specialCharCount = 0;
$allValuesAreObjects = true;
foreach ($keys as $key) {
if (preg_match('/[;=]/', $key) || strlen($key) > 30) {
$specialCharCount++;
}
}
foreach ($value as $v) {
if (!is_array($v) || !$this->isAssoc($v)) {
$allValuesAreObjects = false;
break;
}
}
return $allValuesAreObjects && ($specialCharCount > 0 || count($keys) > 3);
}
private function getBaseType(mixed $value): string
{
if ($value === null) {
return 'null';
}
if (is_bool($value)) {
return 'bool';
}
if (is_int($value)) {
return 'int';
}
if (is_float($value)) {
return 'float';
}
if (is_string($value)) {
return 'string';
}
if (is_array($value)) {
if (empty($value)) {
return 'array';
}
return $this->isAssoc($value) ? 'object' : 'array';
}
return 'mixed';
}
private function isAssoc(array $arr): bool
{
if (empty($arr)) {
return false;
}
return array_keys($arr) !== range(0, count($arr) - 1);
}
// =========================================================================
// Class Registration (merging same-named classes)
// =========================================================================
private function registerClass(string $className, array $schema, int $filesForThisEndpoint, bool $isResponse): void
{
if (!isset($this->classRegistry[$className])) {
$this->classRegistry[$className] = [
'properties' => [],
'totalOccurrences' => 0,
'isResponse' => $isResponse,
];
}
$this->classRegistry[$className]['totalOccurrences'] += $filesForThisEndpoint;
foreach ($schema as $key => $info) {
if (is_numeric($key)) {
continue;
}
$this->registerProperty($className, $key, $info, $filesForThisEndpoint);
}
}
private function registerProperty(string $className, string $key, array $info, int $filesForThisEndpoint): void
{
$registry = &$this->classRegistry[$className]['properties'];
if (!isset($registry[$key])) {
$registry[$key] = [
'types' => [],
'count' => 0,
'hasNull' => false,
'children' => [],
'isDynamicMap' => false,
'nestedChildren' => [],
];
}
// Merge types
foreach ($info['types'] as $type => $_) {
$registry[$key]['types'][$type] = true;
}
// Merge counts
$registry[$key]['count'] += $info['count'];
// Merge hasNull
if ($info['hasNull']) {
$registry[$key]['hasNull'] = true;
}
// Merge isDynamicMap
if ($info['isDynamicMap']) {
$registry[$key]['isDynamicMap'] = true;
}
// Handle nested objects - register as separate class
if (!empty($info['children'])) {
// Check if this is an array of objects - if so, singularize the class name
$isArrayOfObjects = false;
foreach ($info['types'] as $type => $_) {
if (str_starts_with($type, 'array<')) {
$isArrayOfObjects = true;
break;
}
}
$baseName = $this->toPascalCase($key);
$nestedClassName = $isArrayOfObjects ? $this->singularize($baseName) : $baseName;
$this->registerClass($nestedClassName, $info['children'], $info['count'], false);
$registry[$key]['nestedClassName'] = $nestedClassName;
}
// Handle nested array children (array<array<object>>)
if (!empty($info['nestedChildren'])) {
$baseName = $this->toPascalCase($key);
$nestedClassName = $this->singularize($baseName);
$this->registerClass($nestedClassName, $info['nestedChildren'], $filesForThisEndpoint, false);
$registry[$key]['nestedArrayClassName'] = $nestedClassName;
}
}
// =========================================================================
// DTO Generation
// =========================================================================
private function endpointToClassName(string $endpoint): string
{
$parts = explode('/', trim($endpoint, '/'));
$lastPart = end($parts);
return $this->toPascalCase($lastPart) . 'Response';
}
private function toPascalCase(string $str): string
{
$str = str_replace(['-', '_'], ' ', $str);
return str_replace(' ', '', ucwords($str));
}
private function singularize(string $str): string
{
return Str::singular($str);
}
private function writeAllClasses(): void
{
$this->info("\n" . str_repeat('=', 60));
$this->info("Writing classes...");
$this->info(str_repeat('=', 60));
// Write ApiResponse.php to Response folder first
$this->writeApiResponse();
$written = 0;
foreach ($this->classRegistry as $className => $classInfo) {
$isResponse = $classInfo['isResponse'];
$folder = $isResponse ? 'Response' : 'Model';
$namespace = $this->baseNamespace . '\\' . $folder;
$properties = $this->buildProperties($classInfo['properties'], $classInfo['totalOccurrences']);
$this->writeClass($namespace, $className, $properties, $folder, $isResponse);
$written++;
}
$this->info("\nGenerated $written DTO classes + ApiResponse.php");
$this->info("Output: {$this->outputDir}");
}
private function writeApiResponse(): void
{
$dir = $this->outputDir . '/Response';
$filePath = $dir . '/ApiResponse.php';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$namespace = $this->baseNamespace . '\\Response';
$content = <<<PHP
<?php
namespace $namespace;
use Illuminate\Http\Client\Response;
use JMS\Serializer\Naming\IdenticalPropertyNamingStrategy;
use JMS\Serializer\SerializerBuilder;
abstract class ApiResponse
{
public static function parse(Response \$response): static
{
\$data = \$response->body();
\$serializer = SerializerBuilder::create()
->setPropertyNamingStrategy(new IdenticalPropertyNamingStrategy())
->build();
return \$serializer->deserialize(\$data, static::class, 'json');
}
}
PHP;
file_put_contents($filePath, $content);
$this->line(" Response/ApiResponse.php");
}
private function buildProperties(array $registeredProps, int $totalOccurrences): array
{
$properties = [];
foreach ($registeredProps as $key => $info) {
$types = array_keys($info['types']);
$types = array_filter($types, fn($t) => $t !== 'null');
if (empty($types)) {
$types = ['null'];
}
$phpType = $this->resolvePhpType($types);
$jmsType = null;
// Field is missing in some responses -> nullable WITH default null
$isMissingInSome = $info['count'] < $totalOccurrences;
// Field always present but sometimes has explicit null -> nullable WITHOUT default (fail if missing)
$hasExplicitNull = $info['hasNull'];
// Handle nested objects
if (isset($info['nestedClassName'])) {
$nestedClassName = $info['nestedClassName'];
$modelNamespace = $this->baseNamespace . '\\Model';
$nestedFqcn = $modelNamespace . '\\' . $nestedClassName;
if ($info['isDynamicMap']) {
$phpType = 'array';
$jmsType = "array<string, $nestedFqcn>";
} elseif ($this->isArrayType($types)) {
$phpType = 'array';
$jmsType = "array<$nestedFqcn>";
} else {
$phpType = $nestedClassName;
$jmsType = $nestedFqcn;
}
}
// Handle nested array children (array<array<object>>)
if (isset($info['nestedArrayClassName'])) {
$nestedClassName = $info['nestedArrayClassName'];
$modelNamespace = $this->baseNamespace . '\\Model';
$nestedFqcn = $modelNamespace . '\\' . $nestedClassName;
$phpType = 'array';
$jmsType = "array<array<$nestedFqcn>>";
}
// Handle simple array types
if ($jmsType === null && $this->isArrayType($types)) {
$itemType = $this->extractArrayItemType($types);
if ($itemType && $itemType !== 'object') {
$jmsType = "array<$itemType>";
}
}
$properties[] = [
'name' => $key,
'phpType' => $phpType,
'isNullable' => $isMissingInSome || $hasExplicitNull,
'hasDefault' => $isMissingInSome, // only missing fields get default null
'jmsType' => $jmsType,
];
}
return $properties;
}
private function resolvePhpType(array $types): string
{
$baseTypes = [];
foreach ($types as $type) {
if (str_starts_with($type, 'array<')) {
return 'array';
}
$baseTypes[] = $type;
}
if (in_array('int', $baseTypes) && in_array('float', $baseTypes)) {
return 'float';
}
if (in_array('object', $baseTypes)) {
return 'object';
}
if (count($baseTypes) === 1) {
return match ($baseTypes[0]) {
'string' => 'string',
'int' => 'int',
'float' => 'float',
'bool' => 'bool',
'array' => 'array',
'null' => 'mixed',
default => 'mixed',
};
}
return 'mixed';
}
private function isArrayType(array $types): bool
{
foreach ($types as $type) {
if (str_starts_with($type, 'array')) {
return true;
}
}
return false;
}
private function extractArrayItemType(array $types): ?string
{
foreach ($types as $type) {
if (preg_match('/^array<(.+)>$/', $type, $matches)) {
return $matches[1];
}
}
return null;
}
private function writeClass(string $namespace, string $className, array $properties, string $folder, bool $isResponse = false): void
{
$dir = $this->outputDir . '/' . $folder;
$filePath = $dir . '/' . $className . '.php';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$content = $this->buildClassContent($namespace, $className, $properties, $isResponse);
file_put_contents($filePath, $content);
$relativePath = str_replace($this->outputDir . '/', '', $filePath);
$this->line(" $relativePath");
}
private function buildClassContent(string $namespace, string $className, array $properties, bool $isResponse = false): string
{
$lines = ["<?php", "", "namespace $namespace;", ""];
$imports = [];
// Collect Model class imports for properties that use them
$modelNamespace = $this->baseNamespace . '\\Model';
foreach ($properties as $prop) {
$phpType = $prop['phpType'];
// If phpType is a class name (not a primitive), it's a Model class
if (!in_array($phpType, ['string', 'int', 'float', 'bool', 'array', 'object', 'mixed'], true)) {
$imports[$modelNamespace . '\\' . $phpType] = true;
}
}
// Determine if we need JMS Type import (also needed for mixed, array, object types)
$needsJmsType = false;
foreach ($properties as $prop) {
if ($prop['jmsType'] !== null || in_array($prop['phpType'], ['mixed', 'array', 'object'], true)) {
$needsJmsType = true;
break;
}
}
// Sort and write imports
$importKeys = array_keys($imports);
sort($importKeys);
foreach ($importKeys as $import) {
$lines[] = "use $import;";
}
if ($needsJmsType) {
$lines[] = "use JMS\\Serializer\\Annotation\\Type;";
}
if (!empty($importKeys) || $needsJmsType) {
$lines[] = "";
}
// Class declaration - Response classes extend ApiResponse
if ($isResponse) {
$lines[] = "class $className extends ApiResponse";
} else {
$lines[] = "class $className";
}
$lines[] = "{";
foreach ($properties as $i => $prop) {
if ($i > 0) {
$lines[] = "";
}
$phpType = $prop['phpType'];
$isNullable = $prop['isNullable'];
// Determine type declaration
// mixed already includes null, so don't use ?mixed
if ($phpType === 'mixed') {
$typeDecl = 'mixed';
} elseif ($isNullable) {
$typeDecl = "?$phpType";
} else {
$typeDecl = $phpType;
}
// JMS Type annotation (required for mixed, array, and object types too)
if ($prop['jmsType'] !== null) {
$lines[] = " #[Type('{$prop['jmsType']}')]";
} elseif ($phpType === 'mixed') {
$lines[] = " #[Type('mixed')]";
} elseif ($phpType === 'array') {
$lines[] = " #[Type('array')]";
} elseif ($phpType === 'object') {
$lines[] = " #[Type('array')]"; // JMS doesn't support 'object', use array
}
// Only add default value if field is missing in some responses
// (explicit null values = nullable but NO default, so it fails if field missing)
// mixed is always nullable so give it default too
$hasDefault = $prop['hasDefault'] ?? false;
if ($hasDefault || $phpType === 'mixed') {
$lines[] = " public $typeDecl \${$prop['name']} = null;";
} else {
$lines[] = " public $typeDecl \${$prop['name']};";
}
}
$lines[] = "}";
$lines[] = "";
return implode("\n", $lines);
}
// =========================================================================
// Schema Printing (for --analyze mode)
// =========================================================================
private function printSchema(array $schema, int $indent, int $totalFiles): void
{
$prefix = str_repeat('│ ', $indent);
$keys = array_keys($schema);
$lastKey = end($keys);
foreach ($schema as $key => $info) {
$isLast = ($key === $lastKey);
$branch = $isLast ? '└── ' : '├── ';
$isNullable = $info['count'] < $totalFiles;
$nullableMarker = $isNullable ? '?' : '';
$types = array_keys($info['types']);
$types = array_filter($types, fn($t) => $t !== 'null');
if (empty($types)) {
$types = ['null'];
}
$typeStr = implode('|', $types);
if ($info['isDynamicMap']) {
$typeStr = 'map<string, object>';
}
$line = $prefix . $branch . $key . ': ' . $nullableMarker . $typeStr;
$this->line($line);
if (!empty($info['children'])) {
$childTotalFiles = $info['isDynamicMap'] ? $info['count'] : $totalFiles;
$this->printSchema($info['children'], $indent + 1, $childTotalFiles);
}
if (!empty($info['nestedChildren'])) {
$this->line($prefix . '│ ' . '└── []: object');
$this->printSchema($info['nestedChildren'], $indent + 2, $totalFiles);
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment