@@ -48,14 +48,6 @@ final class StreamWrapper implements StreamWrapperInterface
4848 */
4949 private static array $ statCache = [];
5050
51- /**
52- * In-memory cache for static-path negative misses only (e.g. vendor).
53- * Never stores negative misses for dynamic paths (var/cache, storage).
54- *
55- * @var array<string, true>
56- */
57- private static array $ staticNegativeStatCache = [];
58-
5951 /**
6052 * In-memory cache for isApplicationFile path decisions.
6153 *
@@ -83,7 +75,6 @@ final class StreamWrapper implements StreamWrapperInterface
8375 public static function reset (): void
8476 {
8577 self ::$ statCache = [];
86- self ::$ staticNegativeStatCache = [];
8778 self ::$ appFileDecisionCache = [];
8879 PathMatcher::reset ();
8980 }
@@ -194,47 +185,20 @@ public static function transformSource(string $source, string $filePath = ''): s
194185 */
195186 public function stream_open (string $ path , string $ mode , int $ options , ?string &$ openedPath ): bool
196187 {
197- if ($ mode !== 'r ' && $ mode !== 'rb ' && $ mode !== 'rt ' ) {
198- return $ this ->openDirectHandle ($ path , $ mode );
199- }
200-
188+ // Fast-path: non-PHP files are never transformed
201189 if (! str_ends_with (strtolower ($ path ), '.php ' )) {
202190 return $ this ->openDirectHandle ($ path , $ mode );
203191 }
204192
205- if (! Config::isEnabled ()) {
206- return $ this ->openDirectHandle ($ path , $ mode );
207- }
208-
209- $ normalizedRaw = str_replace ('\\' , '/ ' , $ path );
210-
211- if (! PathMatcher::mayPathBeIncluded ($ normalizedRaw )) {
212- return $ this ->openDirectHandle ($ path , $ mode );
213- }
214-
215- if (isset (self ::$ appFileDecisionCache [$ normalizedRaw ])) {
216- if (! self ::$ appFileDecisionCache [$ normalizedRaw ]) {
217- return $ this ->openDirectHandle ($ path , $ mode );
218- }
219- }
220-
221193 self ::unregister ();
222- $ exists = ( bool ) self ::silent (fn () => file_exists ($ path ));
223- $ resolvedPath = $ exists ? self :: silent ( fn () => realpath ($ path )) : false ;
194+ $ exists = self ::silent (fn () => file_exists ($ path ));
195+ $ resolvedPath = $ exists ? realpath ($ path ) : '' ;
224196 self ::register ();
225197
226- if (! $ exists || $ resolvedPath === false ) {
227- return $ this ->openDirectHandle ($ path , $ mode );
228- }
229-
230- $ normalizedResolved = str_replace ('\\' , '/ ' , $ resolvedPath );
231-
232- if (! self ::isApplicationFile ($ path , $ resolvedPath )) {
233- return $ this ->openDirectHandle ($ normalizedResolved , $ mode );
234- }
198+ $ isAppFile = $ exists && ! self ::isReadOnlyCall () && self ::isApplicationFile ($ path , $ resolvedPath );
235199
236- if (self :: isReadOnlyCall () ) {
237- return $ this ->openDirectHandle ($ normalizedResolved , $ mode );
200+ if (! $ isAppFile || $ resolvedPath === false ) {
201+ return $ this ->openDirectHandle (( $ resolvedPath !== false && $ resolvedPath !== '' ) ? $ resolvedPath : $ path , $ mode );
238202 }
239203
240204 self ::unregister ();
@@ -248,20 +212,6 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
248212 return $ success ;
249213 }
250214
251- /**
252- * Determines if the stream_open call is for reading raw file contents/snippets
253- * (e.g. error screen renderers) rather than PHP engine execution.
254- */
255- private static function isReadOnlyCall (): bool
256- {
257- $ trace = debug_backtrace (DEBUG_BACKTRACE_IGNORE_ARGS , 3 );
258-
259- $ caller1 = strtolower ($ trace [1 ]['function ' ] ?? '' );
260- $ caller2 = strtolower ($ trace [2 ]['function ' ] ?? '' );
261-
262- return isset (self ::READ_ONLY_FUNCTIONS [$ caller1 ]) || isset (self ::READ_ONLY_FUNCTIONS [$ caller2 ]);
263- }
264-
265215 /**
266216 * Opens a raw file handle directly without stream interception.
267217 */
@@ -396,36 +346,25 @@ public function stream_close(): void
396346
397347 /**
398348 * High-speed stat resolution with $O(1)$ memoization cache.
399- * Caches positive stat hits.
400- * Only caches negative misses for STATIC directories (vendor, tests).
401- * NEVER caches negative misses for dynamic writable paths (var/cache, storage),
402- * guaranteeing Symfony/Shopware cache creation is detected immediately.
349+ * Never caches false (negative lookups) so newly created directories and files are immediately discovered.
403350 *
404351 * @return array<int|string, int>|false
405352 */
406353 public function url_stat (string $ path , int $ flags ): array |false
407354 {
408355 $ normalized = str_replace ('\\' , '/ ' , $ path );
409-
410356 if (isset (self ::$ statCache [$ normalized ])) {
411357 return self ::$ statCache [$ normalized ];
412358 }
413359
414- if (isset (self ::$ staticNegativeStatCache [$ normalized ])) {
415- return false ;
416- }
417-
418360 self ::unregister ();
419361 /** @var array<int|string, int>|false $result */
420362 $ result = self ::silent (fn () => stat ($ path ));
421363 self ::register ();
422364
365+ // Only cache positive results (existing files/dirs)
423366 if ($ result !== false ) {
424367 self ::$ statCache [$ normalized ] = $ result ;
425- } else {
426- if (! PathMatcher::isDynamicWritablePath ($ normalized )) {
427- self ::$ staticNegativeStatCache [$ normalized ] = true ;
428- }
429368 }
430369
431370 return $ result ;
@@ -434,7 +373,7 @@ public function url_stat(string $path, int $flags): array|false
434373 public function stream_metadata (string $ path , int $ option , mixed $ value ): bool
435374 {
436375 $ normalized = str_replace ('\\' , '/ ' , $ path );
437- unset(self ::$ statCache [$ normalized ], self :: $ staticNegativeStatCache [ $ normalized ] );
376+ unset(self ::$ statCache [$ normalized ]);
438377
439378 self ::unregister ();
440379 $ result = false ;
@@ -498,10 +437,10 @@ public function dir_closedir(): bool
498437 public function mkdir (string $ path , int $ mode , int $ options ): bool
499438 {
500439 $ normalized = str_replace ('\\' , '/ ' , $ path );
501- unset(self ::$ statCache [$ normalized ], self :: $ staticNegativeStatCache [ $ normalized ] );
440+ unset(self ::$ statCache [$ normalized ]);
502441
503442 self ::unregister ();
504- $ result = (bool ) self ::silent (fn () => mkdir ($ path , $ mode , ($ options & STREAM_MKDIR_RECURSIVE ) !== 0 ));
443+ $ result = (bool ) self ::silent (fn () => mkdir ($ path , $ mode , (bool ) ( $ options & STREAM_MKDIR_RECURSIVE )));
505444 self ::register ();
506445
507446 return $ result ;
@@ -510,7 +449,7 @@ public function mkdir(string $path, int $mode, int $options): bool
510449 public function rmdir (string $ path , int $ options ): bool
511450 {
512451 $ normalized = str_replace ('\\' , '/ ' , $ path );
513- unset(self ::$ statCache [$ normalized ], self :: $ staticNegativeStatCache [ $ normalized ] );
452+ unset(self ::$ statCache [$ normalized ]);
514453
515454 self ::unregister ();
516455 $ result = (bool ) self ::silent (fn () => rmdir ($ path ));
@@ -522,7 +461,7 @@ public function rmdir(string $path, int $options): bool
522461 public function unlink (string $ path ): bool
523462 {
524463 $ normalized = str_replace ('\\' , '/ ' , $ path );
525- unset(self ::$ statCache [$ normalized ], self :: $ staticNegativeStatCache [ $ normalized ] );
464+ unset(self ::$ statCache [$ normalized ]);
526465
527466 self ::unregister ();
528467 $ result = (bool ) self ::silent (fn () => unlink ($ path ));
@@ -535,12 +474,7 @@ public function rename(string $pathFrom, string $pathTo): bool
535474 {
536475 $ normFrom = str_replace ('\\' , '/ ' , $ pathFrom );
537476 $ normTo = str_replace ('\\' , '/ ' , $ pathTo );
538- unset(
539- self ::$ statCache [$ normFrom ],
540- self ::$ statCache [$ normTo ],
541- self ::$ staticNegativeStatCache [$ normFrom ],
542- self ::$ staticNegativeStatCache [$ normTo ]
543- );
477+ unset(self ::$ statCache [$ normFrom ], self ::$ statCache [$ normTo ]);
544478
545479 self ::unregister ();
546480 $ result = (bool ) self ::silent (fn () => rename ($ pathFrom , $ pathTo ));
@@ -549,6 +483,20 @@ public function rename(string $pathFrom, string $pathTo): bool
549483 return $ result ;
550484 }
551485
486+ /**
487+ * Determines if the current stream_open call is directly for reading file contents
488+ * rather than PHP engine's require/include execution.
489+ */
490+ private static function isReadOnlyCall (): bool
491+ {
492+ $ trace = debug_backtrace (DEBUG_BACKTRACE_IGNORE_ARGS , 3 );
493+
494+ $ caller1 = strtolower ($ trace [1 ]['function ' ] ?? '' );
495+ $ caller2 = strtolower ($ trace [2 ]['function ' ] ?? '' );
496+
497+ return isset (self ::READ_ONLY_FUNCTIONS [$ caller1 ]) || isset (self ::READ_ONLY_FUNCTIONS [$ caller2 ]);
498+ }
499+
552500 /**
553501 * Executes a callback while temporarily suppressing PHP error and warning handlers.
554502 *
@@ -715,4 +663,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat
715663
716664 SpecialTypeResolver::seedFileMetadata ($ filePath , $ namespace , $ imports , $ classTraitUseDocs );
717665 }
718- }
666+ }
0 commit comments