55use Closure ;
66use Illuminate \Support \Facades \Cache ;
77use Illuminate \Support \Facades \Log ;
8+ use Illuminate \Support \Str ;
89
910/**
1011 * API Response Cache Middleware
@@ -267,12 +268,17 @@ protected function indexCacheKey($store, $cacheKey, array $tags, $ttl)
267268 }
268269
269270 try {
270- $ indexKey = $ this ->getTagIndexKey ($ tags );
271- $ keys = $ store ->get ($ indexKey , []);
272- $ keys [$ cacheKey ] = now ()->addSeconds ($ ttl )->getTimestamp ();
271+ $ expiry = now ()->addSeconds ($ ttl )->getTimestamp ();
272+ $ indexTtl = max ($ ttl , 600 );
273273
274- // Keep index fresh slightly longer than cache TTL to ensure cleanup
275- $ store ->put ($ indexKey , $ keys , max ($ ttl , 600 ));
274+ foreach (array_unique ($ tags ) as $ tag ) {
275+ $ indexKey = $ this ->getTagStorageKey ($ tag );
276+ $ entries = $ store ->get ($ indexKey , []);
277+ $ entries = $ this ->pruneExpiredIndexEntries ($ entries );
278+ $ entries [$ cacheKey ] = $ expiry ;
279+
280+ $ store ->put ($ indexKey , $ entries , $ indexTtl );
281+ }
276282 } catch (\Exception $ e ) {
277283 Log::debug ('API cache index write failed ' , [
278284 'key ' => $ cacheKey ,
@@ -291,42 +297,66 @@ protected function indexCacheKey($store, $cacheKey, array $tags, $ttl)
291297 */
292298 protected function flushIndexedKeys ($ store , array $ tags )
293299 {
300+ $ invalidated = false ;
301+
294302 try {
295- $ indexKey = $ this ->getTagIndexKey ($ tags );
296- $ keys = $ store ->get ($ indexKey , []);
303+ foreach (array_unique ($ tags ) as $ tag ) {
304+ $ indexKey = $ this ->getTagStorageKey ($ tag );
305+ $ entries = $ store ->get ($ indexKey , []);
297306
298- if (empty ($ keys )) {
299- return false ;
300- }
307+ if (empty ($ entries )) {
308+ continue ;
309+ }
301310
302- foreach (array_keys ($ keys ) as $ cacheKey ) {
303- $ store ->forget ($ cacheKey );
304- }
311+ foreach (array_keys ($ entries ) as $ cacheKey ) {
312+ $ store ->forget ($ cacheKey );
313+ }
305314
306- $ store ->forget ($ indexKey );
307-
308- return true ;
315+ $ store ->forget ($ indexKey );
316+ $ invalidated = true ;
317+ }
309318 } catch (\Exception $ e ) {
310319 Log::debug ('API cache index flush failed ' , [
311320 'tags ' => $ tags ,
312321 'error ' => $ e ->getMessage (),
313322 ]);
314323 }
315324
316- return false ;
325+ return $ invalidated ;
317326 }
318327
319328 /**
320- * Build deterministic index key for a given tag list .
329+ * Build deterministic storage key for a given tag.
321330 *
322- * @param array $tags
331+ * @param string $tag
323332 * @return string
324333 */
325- protected function getTagIndexKey ( array $ tags )
334+ protected function getTagStorageKey ( $ tag )
326335 {
327- sort ($ tags );
336+ return self ::CACHE_PREFIX . 'tag_index: ' . md5 ($ tag );
337+ }
328338
329- return self ::CACHE_PREFIX . 'tag_index: ' . md5 (implode ('| ' , $ tags ));
339+ /**
340+ * Remove expired cache key references from the index.
341+ *
342+ * @param array $entries
343+ * @return array
344+ */
345+ protected function pruneExpiredIndexEntries (array $ entries )
346+ {
347+ if (empty ($ entries )) {
348+ return $ entries ;
349+ }
350+
351+ $ now = now ()->getTimestamp ();
352+
353+ foreach ($ entries as $ cacheKey => $ timestamp ) {
354+ if ($ timestamp <= $ now ) {
355+ unset($ entries [$ cacheKey ]);
356+ }
357+ }
358+
359+ return $ entries ;
330360 }
331361
332362 /**
@@ -415,29 +445,160 @@ protected function getCacheTags($request)
415445 {
416446 $ tags = [];
417447
418- // Add route-based tag
419448 $ route = $ request ->route ();
420449 if ($ route && $ route ->getName ()) {
421450 $ tags [] = 'route: ' . $ route ->getName ();
422451 }
423452
424- // Add path-based tag
425- $ pathSegments = explode ('/ ' , trim ($ request ->path (), '/ ' ));
426- if (! empty ($ pathSegments [0 ])) {
427- $ tags [] = 'path: ' . $ pathSegments [0 ];
428- }
453+ $ tags = array_merge ($ tags , $ this ->buildDynamicPathTags ($ request ));
429454
430- // Add custom tags from route
431455 if ($ route ) {
432456 $ customTags = $ route ->getAction ('cache_tags ' );
433457 if (is_array ($ customTags )) {
434458 $ tags = array_merge ($ tags , $ customTags );
435459 }
436460 }
437461
462+ $ tags = array_values (array_unique (array_filter ($ tags )));
463+
438464 return $ tags ;
439465 }
440466
467+ /**
468+ * Build dynamic cache tags derived from the request path.
469+ *
470+ * @param \Illuminate\Http\Request $request
471+ * @return array
472+ */
473+ protected function buildDynamicPathTags ($ request )
474+ {
475+ if (! config ('laravel-page-speed.api.cache.dynamic_tagging.enabled ' , true )) {
476+ $ fallbackSegment = Str::lower (trim ($ request ->segment (1 )));
477+
478+ return $ fallbackSegment ? ['path: ' . $ fallbackSegment ] : [];
479+ }
480+
481+ $ segments = $ this ->getRelevantSegments ($ request );
482+
483+ if (empty ($ segments )) {
484+ return [];
485+ }
486+
487+ $ maxDepth = (int ) config ('laravel-page-speed.api.cache.dynamic_tagging.max_depth ' , 5 );
488+ if ($ maxDepth > 0 ) {
489+ $ segments = array_slice ($ segments , 0 , $ maxDepth );
490+ }
491+
492+ $ tags = [];
493+
494+ // Preserve compatibility with legacy path tag
495+ $ tags [] = 'path: ' . $ segments [0 ];
496+ $ tags [] = 'collection: ' . $ segments [0 ];
497+
498+ // Cumulative tags with raw segments
499+ $ cumulative = [];
500+ foreach ($ segments as $ segment ) {
501+ $ cumulative [] = $ segment ;
502+ $ tags [] = 'resource: ' . implode (': ' , $ cumulative );
503+ }
504+
505+ $ normalizedSegments = $ this ->normalizeSegments ($ segments );
506+ $ normalizedCumulative = [];
507+ foreach ($ normalizedSegments as $ segment ) {
508+ $ normalizedCumulative [] = $ segment ;
509+ $ tags [] = 'resource: ' . implode (': ' , $ normalizedCumulative );
510+ }
511+
512+ $ tags [] = 'fqn: ' . implode ('/ ' , $ segments );
513+ $ tags [] = 'fqn: ' . implode ('/ ' , $ normalizedSegments );
514+
515+ return array_values (array_unique ($ tags ));
516+ }
517+
518+ /**
519+ * Filter request segments to relevant portions for tagging.
520+ *
521+ * @param \Illuminate\Http\Request $request
522+ * @return array
523+ */
524+ protected function getRelevantSegments ($ request )
525+ {
526+ $ segments = array_values (array_filter (explode ('/ ' , trim ($ request ->path (), '/ ' )), 'strlen ' ));
527+
528+ if (empty ($ segments )) {
529+ return [];
530+ }
531+
532+ $ ignore = array_map ('strtolower ' , config ('laravel-page-speed.api.cache.dynamic_tagging.ignore_segments ' , ['api ' ]));
533+
534+ $ segments = array_values (array_filter ($ segments , function ($ segment ) use ($ ignore ) {
535+ return ! in_array (strtolower ($ segment ), $ ignore , true );
536+ }));
537+
538+ return array_map (function ($ segment ) {
539+ return Str::lower ($ segment );
540+ }, $ segments );
541+ }
542+
543+ /**
544+ * Normalize path segments by replacing identifiers with placeholders when enabled.
545+ *
546+ * @param array $segments
547+ * @return array
548+ */
549+ protected function normalizeSegments (array $ segments )
550+ {
551+ if (! config ('laravel-page-speed.api.cache.dynamic_tagging.normalize_ids ' , true )) {
552+ return $ segments ;
553+ }
554+
555+ return array_map (function ($ segment ) {
556+ return $ this ->normalizeSegment ($ segment );
557+ }, $ segments );
558+ }
559+
560+ /**
561+ * Normalize a single segment.
562+ *
563+ * @param string $segment
564+ * @return string
565+ */
566+ protected function normalizeSegment ($ segment )
567+ {
568+ return $ this ->isIdentifierSegment ($ segment ) ? '{id} ' : $ segment ;
569+ }
570+
571+ /**
572+ * Determine if the segment represents an identifier.
573+ *
574+ * @param string $segment
575+ * @return bool
576+ */
577+ protected function isIdentifierSegment ($ segment )
578+ {
579+ if ($ segment === '' ) {
580+ return false ;
581+ }
582+
583+ if (preg_match ('/^\d+$/ ' , $ segment )) {
584+ return true ;
585+ }
586+
587+ if (preg_match ('/^[0-9a-f]{24}$/i ' , $ segment ) || preg_match ('/^[0-9a-f]{32}$/i ' , $ segment ) || preg_match ('/^[0-9a-f]{40}$/i ' , $ segment )) {
588+ return true ;
589+ }
590+
591+ if (preg_match ('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i ' , $ segment )) {
592+ return true ;
593+ }
594+
595+ if (preg_match ('/^[0-9A-HJKMNP-TV-Z]{26}$/ ' , strtoupper ($ segment ))) { // ULID support
596+ return true ;
597+ }
598+
599+ return false ;
600+ }
601+
441602 /**
442603 * Determine if the request should be cached.
443604 *
0 commit comments