Cache
Com utilitzar el sistema de cache a Laravel: drivers, operacions, tags, locks atòmics i estratègies de rendiment.
Per què la cache importa#
Cada petició a una aplicació web consumeix recursos: temps de CPU, memòria, connexions a la base de dades, crides a APIs externes. Moltes d'aquestes operacions són repetitives: la llista de categories del menú principal, el recompte d'articles publicats, el resultat d'una consulta de cerca popular, la resposta d'una API de conversió de divises. Sense cache, cada petició recalcula el mateix resultat des de zero, malgrat que les dades no hagin canviat.
La cache és un sistema d'emmagatzematge temporal que guarda el resultat d'operacions costoses perquè les peticions posteriors el puguin reutilitzar sense recalcular-lo. Quan una consulta a la base de dades triga 200 ms i es fa 10.000 vegades al dia, afegir cache pot reduir el temps de resposta a menys d'1 ms per a 9.999 d'aquestes peticions. El benefici és acumulatiu: quantes més operacions es cachegen, menys càrrega pateix la base de dades i més ràpida és l'aplicació.
Laravel proporciona una API unificada per treballar amb cache, seguint el patró de drivers que utilitza per a molts altres components. Pots començar amb el driver file durant el desenvolupament i canviar a Redis en producció sense modificar ni una línia de codi. La facade Cache és el punt d'entrada principal i ofereix mètodes per guardar, llegir, eliminar i manipular dades cachejades.
Configuració#
La configuració de la cache es troba al fitxer config/cache.php. Aquí es defineix el store per defecte, els stores disponibles i el prefix de les claus. Laravel ve amb diversos drivers preconfigurats, cadascun amb característiques i casos d'ús diferents.
El driver file guarda les dades cachejades en fitxers al directori storage/framework/cache/data. És el driver per defecte i funciona bé per a desenvolupament, però no és ideal per a producció perquè les operacions de lectura i escriptura al disc són lentes comparades amb memòria. El driver redis emmagatzema les dades en memòria amb Redis, oferint operacions en microsegons i suport per a funcionalitats avançades com tags i locks atòmics. El driver memcached és similar a Redis però amb menys funcionalitats. El driver database guarda la cache en una taula de la base de dades, cosa que pot ser útil quan no tens accés a Redis. El driver array manté la cache en memòria durant la petició actual i la descarta al final: és útil per a tests:
// config/cache.php
return [
'default' => env('CACHE_STORE', 'database'),
'stores' => [
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'redis' => [
'driver' => 'redis',
'connection' => env('CACHE_REDIS_CONNECTION', 'cache'),
'lock_connection' => env('CACHE_REDIS_LOCK_CONNECTION', 'default'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'array' => [
'driver' => 'array',
'serialize' => false,
],
],
'prefix' => env('CACHE_PREFIX', 'laravel_cache'),
];Per canviar el driver de cache, modifica la variable CACHE_STORE al fitxer .env. En producció, Redis és l'opció recomanada per la seva velocitat i funcionalitats avançades:
CACHE_STORE=redisSi fas servir el driver database, necessites crear la taula de cache. Laravel inclou una migració per defecte, però si no la tens, la pots crear amb Artisan:
php artisan make:cache-table
php artisan migrateOperacions bàsiques#
La facade Cache proporciona una API senzilla i consistent per a totes les operacions de cache. Les operacions més habituals són guardar un valor amb un temps d'expiració, recuperar un valor i eliminar-lo quan ja no és necessari.
Guardar valors#
El mètode put guarda un valor amb una clau i un temps de vida (TTL). El TTL pot ser un nombre de segons o una instància de DateTime/Carbon. Quan el TTL expira, el valor s'elimina automàticament la pròxima vegada que algú intenta accedir-hi. Escollir un bon TTL és un compromís: un TTL massa curt fa que la cache sigui poc efectiva, mentre que un TTL massa llarg pot servir dades obsoletes:
use Illuminate\Support\Facades\Cache;
// Guardar durant 10 minuts (amb Carbon)
Cache::put('estadistiques', $dades, now()->addMinutes(10));
// Guardar durant 3600 segons
Cache::put('configuracio', $config, 3600);
// Guardar només si la clau no existeix
Cache::add('bloqueig', 'actiu', now()->addMinutes(5));
// Retorna true si s'ha guardat, false si la clau ja existiaRecuperar valors#
El mètode get recupera un valor de la cache. Si la clau no existeix o ha expirat, retorna null per defecte. Pots especificar un valor per defecte com a segon paràmetre, que es retorna quan la clau no es troba. El mètode has permet comprovar si una clau existeix sense recuperar-ne el valor:
// Recuperar (retorna null si no existeix)
$valor = Cache::get('estadistiques');
// Recuperar amb valor per defecte
$valor = Cache::get('estadistiques', 'sense dades');
// Recuperar amb closure com a valor per defecte
$valor = Cache::get('estadistiques', function () {
return 'calculant...';
});
// Comprovar si existeix
if (Cache::has('estadistiques')) {
// La clau existeix i no ha expirat
}
// Recuperar i eliminar
$valor = Cache::pull('clau-temporal');
// Retorna el valor i l'elimina de la cacheEliminar valors#
El mètode forget elimina una clau específica de la cache. El mètode flush elimina totes les claus, cosa que pot ser destructiva en producció si altres parts de l'aplicació (o altres aplicacions) comparteixen el mateix store de cache. Utilitza flush amb precaució:
// Eliminar una clau
Cache::forget('estadistiques');
// Eliminar totes les claus (perillos en producció)
Cache::flush();Increment i decrement#
Per a valors numèrics, els mètodes increment i decrement permeten modificar el valor sense llegir-lo i reescriure'l. Aquestes operacions són atòmiques en drivers com Redis, cosa que les fa segures en entorns concurrents. Són ideals per a comptadors de visites, comptadors de peticions o qualsevol mètrica que canvia freqüentment:
// Incrementar en 1
Cache::increment('visites');
// Incrementar en un valor específic
Cache::increment('visites', 5);
// Decrementar en 1
Cache::decrement('stock-producte-42');
// Decrementar en un valor específic
Cache::decrement('stock-producte-42', 3);El patró remember#
El mètode remember és probablement el més útil de tota l'API de cache. Combina la lectura i l'escriptura en una sola operació: si la clau existeix a la cache, retorna el valor cachetat. Si no existeix, executa la closure, guarda el resultat a la cache amb el TTL indicat i el retorna. Això elimina la necessitat d'escriure lògica condicional "si existeix, retorna; si no, calcula i guarda".
El patró remember és tan comú que hauries de pensar en ell sempre que tinguis una operació costosa que es repeteix amb freqüència. Consultes complexes a la base de dades, crides a APIs externes, càlculs matemàtics intensius, processos de transformació de dades: tots són candidats perfectes per a remember:
// Cachejar una consulta durant 10 minuts
$articles = Cache::remember('articles:publicats', now()->addMinutes(10), function () {
return Article::where('published', true)
->with('author', 'tags')
->latest()
->take(20)
->get();
});
// Cachejar el resultat d'una API externa durant 1 hora
$canviDivises = Cache::remember('divises:eur-usd', now()->addHour(), function () {
return Http::get('https://api.exchangerate.host/latest', [
'base' => 'EUR',
'symbols' => 'USD',
])->json();
});
// Cachejar estadístiques complexes durant 30 minuts
$estadistiques = Cache::remember('dashboard:stats', now()->addMinutes(30), function () {
return [
'usuaris_actius' => User::where('last_login', '>', now()->subDays(30))->count(),
'articles_publicats' => Article::where('published', true)->count(),
'comentaris_avui' => Comment::whereDate('created_at', today())->count(),
'ingressos_mes' => Order::whereMonth('created_at', now()->month)->sum('total'),
];
});rememberForever#
El mètode rememberForever funciona igual que remember però sense TTL: el valor es manté a la cache indefinidament fins que l'elimines manualment. Això és útil per a dades que canvien molt poques vegades, com la configuració de l'aplicació, les llistes de països o les categories principals:
// Cachejar per sempre (fins que s'invalidi manualment)
$categories = Cache::rememberForever('categories:menu', function () {
return Category::where('visible', true)
->orderBy('position')
->get();
});
$paisos = Cache::rememberForever('paisos:tots', function () {
return Country::orderBy('name')->pluck('name', 'code');
});Cache::forever i quan fer-lo servir#
El mètode forever guarda un valor a la cache sense data d'expiració. A diferència de rememberForever, no executa cap closure: simplement guarda el valor que li passes. El valor romandrà a la cache fins que l'eliminis explícitament amb forget o fins que facis flush.
Utilitzar forever és adequat per a dades que controles completament i que invalides manualment quan canvien. Per exemple, quan un administrador actualitza la configuració del lloc, guardes la nova configuració amb forever i la invalides quan torna a canviar-la. El perill de forever és oblidar invalidar-lo: si les dades canvien i no elimines la cache, l'aplicació seguirà servint dades obsoletes indefinidament:
// Guardar per sempre
Cache::forever('config:lloc', [
'nom' => 'La Meva App',
'idioma' => 'ca',
'moneda' => 'EUR',
]);
// Invalidar quan canviï la configuració
public function update(Request $request)
{
Setting::updateOrCreate(
['key' => 'site_name'],
['value' => $request->input('site_name')]
);
Cache::forget('config:lloc');
return back()->with('success', 'Configuració actualitzada.');
}Cache tags#
Els tags permeten agrupar elements de cache relacionats i invalidar-los tots alhora. Això és extremadament útil quan tens múltiples claus de cache que depenen de les mateixes dades. Per exemple, si caches articles per pàgina, per categoria i per autor, quan un article es modifica necessites invalidar totes les caches que el contenen. Sense tags, hauries de recordar i eliminar cada clau individualment. Amb tags, simplement invalides el tag i totes les claus associades desapareixen.
Cal tenir en compte que els tags no estan disponibles amb els drivers file i database. Necessites un driver que suporti tags, com redis, memcached o dynamodb. Aquesta és una de les raons principals per les quals Redis és el driver recomanat per a producció:
// Guardar amb tags
Cache::tags(['articles', 'pàgina:1'])->put('articles:pàgina:1', $articles, 3600);
Cache::tags(['articles', 'autor:5'])->put('articles:autor:5', $articlesAutor, 3600);
Cache::tags(['articles', 'categoria:php'])->put('articles:cat:php', $articlesPHP, 3600);
// Llegir amb tags
$articles = Cache::tags(['articles', 'pàgina:1'])->get('articles:pàgina:1');
// Invalidar tots els articles (totes les claus amb el tag 'articles')
Cache::tags('articles')->flush();
// Invalidar només els articles d'un autor
Cache::tags('autor:5')->flush();Un patró comú és combinar tags amb remember per crear una cache intel·ligent que s'invalida per grups:
// Al controlador
public function index(Request $request)
{
$pagina = $request->input('page', 1);
$clau = "articles:pàgina:{$pagina}";
$articles = Cache::tags(['articles'])->remember($clau, 3600, function () use ($pagina) {
return Article::where('published', true)
->with('author')
->latest()
->paginate(15, ['*'], 'page', $pagina);
});
return view('articles.index', compact('articles'));
}
// A l'observer del model, quan es crea o modifica un article
public function saved(Article $article): void
{
Cache::tags('articles')->flush();
}
public function deleted(Article $article): void
{
Cache::tags('articles')->flush();
}Locks atòmics#
En aplicacions amb alta concurrència, pot passar que dues peticions intentin fer la mateixa operació simultàniament. Per exemple, dues peticions que processen el mateix pagament, dues peticions que generen el mateix informe o dues peticions que actualitzen el mateix comptador. Els locks atòmics permeten garantir que només una petició a la vegada pot executar un bloc de codi crític.
Els locks atòmics funcionen de manera similar als locks de bases de dades, però a nivell d'aplicació i utilitzant el store de cache. Quan una petició adquireix un lock, les altres peticions que intenten adquirir el mateix lock han d'esperar o rebutjar-se. El lock es pot alliberar manualment o automàticament quan expira el temps màxim.
Per fer servir locks atòmics necessites un driver de cache que suporti operacions atòmiques, com redis, memcached o database. El driver file no suporta locks fiables perquè les operacions al sistema de fitxers no són atòmiques:
use Illuminate\Support\Facades\Cache;
$lock = Cache::lock('processant-comanda:123', 10);
if ($lock->get()) {
try {
// Processar la comanda (màxim 10 segons)
$this->processOrder(123);
} finally {
$lock->release();
}
}Esperar un lock amb block#
El mètode block espera un nombre determinat de segons per adquirir el lock. Si el lock es pot adquirir dins d'aquest temps, executa la closure. Si no, llança una LockTimeoutException. Això és útil quan vols que la petició esperi en lloc de fallar immediatament:
use Illuminate\Contracts\Cache\LockTimeoutException;
try {
$result = Cache::lock('generant-informe', 30)->block(10, function () {
// Generar l'informe (lock durant 30 segons, esperar fins a 10 segons)
return $this->generateReport();
});
} catch (LockTimeoutException $e) {
return response()->json([
'message' => 'L\'informe s\'està generant. Torna a intentar-ho d\'aquí uns segons.',
], 409);
}Alliberar un lock forçosament#
De vegades, un procés pot bloquejar-se o fallar sense alliberar el lock. El mètode forceRelease permet alliberar un lock independentment de qui el posseeixi. Utilitza'l amb precaució perquè pot alliberar un lock que un altre procés està utilitzant legítimament:
// Alliberar el lock forçosament
Cache::lock('processant-comanda:123')->forceRelease();Estratègies d'invalidació de cache#
Invalidar la cache correctament és un dels problemes més difícils en informàtica. Si invalides massa aviat, perds l'avantatge de la cache. Si invalides massa tard, serveixes dades obsoletes. Hi ha tres estratègies principals per invalidar la cache, i la majoria d'aplicacions utilitzen una combinació de totes tres.
Invalidació basada en temps (TTL)#
La invalidació per temps és la més senzilla: cada entrada de cache té un temps de vida i s'elimina automàticament quan expira. Aquesta estratègia funciona bé per a dades que no requereixen precisió absoluta. Per exemple, les estadístiques del dashboard poden estar desfasades 5 minuts sense cap problema. La llista de trending topics pot actualitzar-se cada hora. Les cotitzacions de borsa poden cachejar-se durant 1 minut:
// Dades que toleren 5 minuts de retard
Cache::put('dashboard:stats', $stats, now()->addMinutes(5));
// Dades que toleren 1 hora de retard
Cache::put('trending:articles', $trending, now()->addHour());
// Dades que han de ser molt recents
Cache::put('cotitzacions:ibex35', $cotitzacions, now()->addMinute());Invalidació basada en events#
La invalidació per events és la més precisa: quan les dades canvien, un event dispara la invalidació de la cache. Això garanteix que la cache sempre reflecteix l'estat actual de les dades. Laravel facilita aquesta estratègia amb els model observers i els events del sistema:
// app/Observers/ArticleObserver.php
class ArticleObserver
{
public function saved(Article $article): void
{
Cache::forget("article:{$article->id}");
Cache::tags('articles')->flush();
Cache::forget('estadistiques:articles');
}
public function deleted(Article $article): void
{
Cache::forget("article:{$article->id}");
Cache::tags('articles')->flush();
Cache::forget('estadistiques:articles');
}
}// app/Providers/AppServiceProvider.php
use App\Models\Article;
use App\Observers\ArticleObserver;
public function boot(): void
{
Article::observe(ArticleObserver::class);
}Invalidació manual#
La invalidació manual és adequada per a operacions puntuals controlades per l'administrador. Per exemple, un botó "Netejar cache" al panell d'administració, un command d'Artisan per invalidar caches específiques o un webhook que invalida la cache quan un servei extern notifica un canvi:
// Un Artisan command per invalidar caches específiques
// app/Console/Commands/ClearArticleCache.php
class ClearArticleCache extends Command
{
protected $signature = 'cache:clear-articles';
protected $description = 'Netejar la cache d\'articles';
public function handle(): void
{
Cache::tags('articles')->flush();
Cache::forget('estadistiques:articles');
Cache::forget('sitemap');
$this->info('Cache d\'articles netejada.');
}
}php artisan cache:clear-articlesCachejar consultes Eloquent#
Un dels usos més comuns de la cache és emmagatzemar el resultat de consultes a la base de dades. Les consultes complexes amb múltiples joins, subqueries i agregacions poden ser molt costoses, i cachejar-les pot millorar dràsticament el rendiment de l'aplicació.
El patró més senzill és fer servir remember directament al controlador. Construeixes una clau de cache que identifica de manera única la consulta (incloent-hi els paràmetres de filtratge, paginació i ordenació) i guardes el resultat amb un TTL adequat:
public function show(string $slug)
{
$article = Cache::remember("article:{$slug}", now()->addHour(), function () use ($slug) {
return Article::where('slug', $slug)
->where('published', true)
->with(['author', 'tags', 'comments.author'])
->firstOrFail();
});
return view('articles.show', compact('article'));
}Per a consultes que depenen de paràmetres variables (pàgina, filtres, ordenació), la clau de cache ha d'incloure tots els paràmetres que afecten el resultat. Una bona pràctica és construir la clau de manera consistent i determinista:
public function index(Request $request)
{
$pàgina = $request->input('page', 1);
$categoria = $request->input('categoria', 'totes');
$ordre = $request->input('ordre', 'recent');
$clau = "articles:cat:{$categoria}:ordre:{$ordre}:pag:{$pàgina}";
$articles = Cache::tags(['articles'])->remember($clau, now()->addMinutes(15), function () use ($categoria, $ordre) {
$query = Article::where('published', true)->with('author');
if ($categoria !== 'totes') {
$query->whereHas('category', fn ($q) => $q->where('slug', $categoria));
}
return match ($ordre) {
'popular' => $query->orderByDesc('views')->paginate(15),
'comentat' => $query->withCount('comments')->orderByDesc('comments_count')->paginate(15),
default => $query->latest()->paginate(15),
};
});
return view('articles.index', compact('articles'));
}Per invalidar la cache quan el model canvia, utilitza un observer que esborri les claus afectades. Si fas servir tags (com a l'exemple anterior), la invalidació és molt més senzilla perquè pots esborrar totes les claus amb un sol flush del tag:
// app/Observers/ArticleObserver.php
class ArticleObserver
{
public function saved(Article $article): void
{
// Invalidar la cache individual de l'article
Cache::forget("article:{$article->slug}");
// Invalidar totes les llistes d'articles
Cache::tags(['articles'])->flush();
}
}Capçaleres HTTP de cache#
A més de la cache a nivell d'aplicació, les capçaleres HTTP permeten que els navegadors i CDNs cachegin les respostes. Això redueix el nombre de peticions que arriben al servidor i millora la velocitat percebuda per l'usuari. Laravel facilita l'ús de capçaleres de cache amb middleware.
Les capçaleres Cache-Control, ETag i Last-Modified indiquen als clients (navegadors, proxies, CDNs) si poden reutilitzar una resposta guardada o si han de demanar-ne una de nova. El Cache-Control defineix la política de cache (quant de temps es pot cachejar, si es pot compartir entre usuaris). L'ETag és un hash del contingut que permet al client preguntar "ha canviat el contingut des de l'últim cop?". El Last-Modified indica la data de l'última modificació:
// Middleware per afegir capçaleres de cache
// app/Http/Middleware/CacheResponse.php
class CacheResponse
{
public function handle(Request $request, Closure $next, int $minutes = 5): Response
{
$response = $next($request);
$response->headers->set('Cache-Control', "public, max-age=" . ($minutes * 60));
return $response;
}
}// routes/web.php
Route::get('/articles', [ArticleController::class, 'index'])
->middleware('cache.response:10'); // 10 minutsPer a respostes que depenen de l'usuari (com el dashboard), utilitza private en lloc de public al Cache-Control, o no cachegis la resposta:
// Resposta pública (CDN i navegador poden cachejar)
$response->headers->set('Cache-Control', 'public, max-age=3600');
// Resposta privada (només el navegador pot cachejar)
$response->headers->set('Cache-Control', 'private, max-age=300');
// Sense cache (dades sensibles)
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate');Cache a nivell d'aplicació#
Laravel proporciona diversos commands d'Artisan per cachejar els fitxers de configuració, rutes, vistes i events de l'aplicació. Aquests commands són essencials per al rendiment en producció perquè eviten que Laravel hagi de llegir i processar fitxers PHP a cada petició.
La cache de configuració (config:cache) combina tots els fitxers de configuració en un sol fitxer PHP cachetat. Sense aquesta cache, Laravel ha de llegir i fusionar desenes de fitxers de configuració a cada petició. La cache de rutes (route:cache) serialitza les definicions de rutes en un format optimitzat. La cache de vistes (view:cache) precompila totes les plantilles Blade. La cache d'events (event:cache) serialitza el mapeig d'events a listeners:
# Cachejar configuració (fusiona tots els fitxers de config en un)
php artisan config:cache
# Cachejar rutes (serialitza les definicions de rutes)
php artisan route:cache
# Cachejar vistes (precompila les plantilles Blade)
php artisan view:cache
# Cachejar events (serialitza el mapeig events-listeners)
php artisan event:cachePer netejar aquestes caches durant el desenvolupament o quan desplegues canvis:
# Netejar cache de configuració
php artisan config:clear
# Netejar cache de rutes
php artisan route:clear
# Netejar cache de vistes
php artisan view:clear
# Netejar cache d'events
php artisan event:clear
# Netejar totes les caches alhora
php artisan optimize:clearEn un script de desplegament típic, primer neteges les caches antigues i després generes les noves. L'ordre és important perquè algunes caches depenen de la configuració. El command optimize fa config:cache, route:cache i view:cache alhora:
php artisan optimize:clear
php artisan optimizeCal tenir en compte que la cache de configuració no llegeix el fitxer .env en temps d'execució: tots els valors d'env() es resolen en el moment de generar la cache. Això significa que les crides a env() dins del codi de l'aplicació (fora dels fitxers de configuració) retornaran null quan la cache de configuració estigui activa. Per tant, utilitza sempre config() en lloc d'env() al codi de l'aplicació, i reserva env() exclusivament per als fitxers de configuració.
Testing#
Testejar la cache a Laravel és senzill gràcies als mètodes spy i fake de la facade Cache. El spy permet verificar que s'han fet les crides esperades a la cache sense modificar el comportament real. El fake substitueix el driver de cache per un array en memòria, aïllant els tests del store de cache real.
El patró més comú és fer servir Cache::spy() per verificar que el codi guarda, llegeix o invalida la cache correctament:
use Illuminate\Support\Facades\Cache;
test('l\'article es guarda a la cache', function () {
Cache::spy();
$article = Article::factory()->published()->create();
$this->get("/articles/{$article->slug}");
Cache::shouldHaveReceived('remember')
->once()
->with("article:{$article->slug}", \Mockery::any(), \Mockery::any());
});
test('actualitzar un article invalida la cache', function () {
Cache::spy();
$article = Article::factory()->create();
$article->update(['title' => 'Nou títol']);
Cache::shouldHaveReceived('forget')
->with("article:{$article->slug}");
});Per a tests que necessiten que la cache funcioni realment (per verificar que el valor cachetat es retorna correctament), utilitza el driver array en lloc de spy. El driver array manté la cache en memòria durant el test i la descarta al final:
test('la segona petició retorna dades cachejades', function () {
$article = Article::factory()->published()->create();
// Primera petició: executa la consulta i la guarda a la cache
$this->get("/articles/{$article->slug}")
->assertOk();
// Eliminem l'article de la base de dades
$article->delete();
// Segona petició: retorna les dades cachejades (l'article "encara existeix")
$this->get("/articles/{$article->slug}")
->assertOk()
->assertSee($article->title);
});Quan testegis invalidació de cache amb tags, assegura't que el teu entorn de test fa servir un driver que suporti tags (com array amb serialize: true o redis). El driver file no suporta tags i els tests fallaran silenciosament:
// phpunit.xml o .env.testing
// CACHE_STORE=array