@@ -36,6 +36,24 @@ class SchemaProcessor
3636 protected array $ processedSchema = [];
3737 /** @var PropertyInterface[] Collect processed schemas to avoid duplicated classes */
3838 protected array $ processedMergedProperties = [];
39+ /**
40+ * Global index of schemas keyed by the canonical file path or URL returned by
41+ * SchemaProviderInterface::getRef(). Used to deduplicate external $ref resolutions across
42+ * all schema processings, making class generation order-independent.
43+ *
44+ * When a $ref triggers processTopLevelSchema() for a file that the provider has not yet
45+ * reached, the canonical Schema is registered here before property processing begins. If
46+ * the provider later iterates the same file, generateModel() detects the match via the
47+ * combined file-path + content-signature check and returns the already-registered Schema
48+ * without creating a duplicate render job.
49+ *
50+ * Note: for providers such as OpenAPIv3Provider that yield multiple distinct schemas from
51+ * a single source file, each schema has a unique content signature; the signature check
52+ * prevents false-positive deduplication across schemas that merely share the same file.
53+ *
54+ * @var Schema[]
55+ */
56+ protected array $ processedFileSchemas = [];
3957 /** @var string[] */
4058 protected array $ generatedFiles = [];
4159
@@ -120,6 +138,21 @@ protected function generateModel(
120138 return $ this ->processedSchema [$ schemaSignature ];
121139 }
122140
141+ // For initial-class calls: if this exact file+content was already processed eagerly via
142+ // processTopLevelSchema() (triggered by a $ref resolution), reuse that schema to avoid a
143+ // duplicate render job. Both checks are required:
144+ // - The file-path check detects that this file was already processed via a $ref.
145+ // - The signature check ensures we do not short-circuit when a different schema shares
146+ // the same source file (e.g. OpenAPI v3 where all component schemas are yielded from
147+ // the same spec file — each has a unique signature).
148+ if (
149+ $ initialClass
150+ && isset ($ this ->processedSchema [$ schemaSignature ])
151+ && $ this ->getProcessedFileSchema ($ jsonSchema ->getFile ()) !== null
152+ ) {
153+ return $ this ->processedSchema [$ schemaSignature ];
154+ }
155+
123156 $ schema = new Schema (
124157 $ this ->getTargetFileName ($ classPath , $ className ),
125158 $ classPath ,
@@ -130,7 +163,13 @@ protected function generateModel(
130163 $ this ->generatorConfiguration ,
131164 );
132165
166+ // Register by content signature (secondary dedup for content-identical inline schemas).
133167 $ this ->processedSchema [$ schemaSignature ] = $ schema ;
168+ // Register by canonical file path/URL (primary dedup for external $ref resolutions).
169+ // Registering here — before property processing — ensures that any $ref back to this
170+ // file encountered while processing the referencing schema finds this canonical schema
171+ // immediately, regardless of which schema was discovered first by the provider.
172+ $ this ->registerProcessedFileSchema ($ jsonSchema ->getFile (), $ schema );
134173 $ json = $ jsonSchema ->getJson ();
135174 $ json ['type ' ] = 'base ' ;
136175
@@ -310,10 +349,20 @@ function () use ($property, $schema, $mergedPropertySchema): void {
310349 */
311350 protected function setCurrentClassPath (string $ jsonSchemaFile ): void
312351 {
313- $ path = str_replace ($ this ->schemaProvider ->getBaseDirectory (), '' , dirname ($ jsonSchemaFile ));
352+ $ fileDir = str_replace ('\\' , '/ ' , dirname ($ jsonSchemaFile ));
353+ $ baseDir = str_replace ('\\' , '/ ' , $ this ->schemaProvider ->getBaseDirectory ());
354+ $ relative = str_replace ($ baseDir , '' , $ fileDir );
355+
356+ // If the file is outside the provider's base directory, str_replace leaves the absolute
357+ // path untouched. In that case fall back to using just the last directory component so
358+ // the generated class path stays sensible rather than encoding an absolute path.
359+ if ($ relative === $ fileDir ) {
360+ $ relative = basename ($ fileDir );
361+ }
362+
314363 $ pieces = array_map (
315364 static fn (string $ directory ): string => ucfirst ((string ) preg_replace ('/\W/ ' , '' , $ directory )),
316- explode (DIRECTORY_SEPARATOR , $ path ),
365+ explode (' / ' , $ relative ),
317366 );
318367
319368 $ this ->currentClassPath = join ('\\' , array_filter ($ pieces ));
@@ -344,6 +393,62 @@ public function getSchemaProvider(): SchemaProviderInterface
344393 return $ this ->schemaProvider ;
345394 }
346395
396+ public function getProcessedFileSchema (string $ fileKey ): ?Schema
397+ {
398+ return $ this ->processedFileSchemas [$ this ->normaliseFileKey ($ fileKey )] ?? null ;
399+ }
400+
401+ public function registerProcessedFileSchema (string $ fileKey , Schema $ schema ): void
402+ {
403+ $ this ->processedFileSchemas [$ this ->normaliseFileKey ($ fileKey )] = $ schema ;
404+ }
405+
406+ /**
407+ * Normalise a file path or URL to a consistent key for processedFileSchemas.
408+ * On Windows, RecursiveDirectoryIterator may produce backslash-separated paths while
409+ * RefResolverTrait produces forward-slash paths for the same file. Normalising to forward
410+ * slashes ensures the two representations map to the same key.
411+ */
412+ private function normaliseFileKey (string $ fileKey ): string
413+ {
414+ return str_replace ('\\' , '/ ' , $ fileKey );
415+ }
416+
417+ /**
418+ * Process an external schema file with its canonical class name and path, exactly as
419+ * process() would, but without overwriting the current class path / class name context
420+ * (which belongs to the schema that triggered the $ref resolution).
421+ *
422+ * Returns the resulting Schema, or null if the file does not define an object/composition.
423+ *
424+ * @throws SchemaException
425+ */
426+ public function processTopLevelSchema (JsonSchema $ jsonSchema ): ?Schema
427+ {
428+ $ savedClassPath = $ this ->currentClassPath ;
429+ $ savedClassName = $ this ->currentClassName ;
430+
431+ $ this ->setCurrentClassPath ($ jsonSchema ->getFile ());
432+ $ this ->currentClassName = $ this ->generatorConfiguration ->getClassNameGenerator ()->getClassName (
433+ str_ireplace ('.json ' , '' , basename ($ jsonSchema ->getFile ())),
434+ $ jsonSchema ,
435+ false ,
436+ );
437+
438+ $ schema = $ this ->processSchema (
439+ $ jsonSchema ,
440+ $ this ->currentClassPath ,
441+ $ this ->currentClassName ,
442+ new SchemaDefinitionDictionary ($ jsonSchema ),
443+ true ,
444+ );
445+
446+ $ this ->currentClassPath = $ savedClassPath ;
447+ $ this ->currentClassName = $ savedClassName ;
448+
449+ return $ schema ;
450+ }
451+
347452 private function getTargetFileName (string $ classPath , string $ className ): string
348453 {
349454 return join (
0 commit comments