---
title: "La key fantasma de Cache::flexible() en Laravel"
excerpt: "Cache::flexible() guarda una clave interna que nunca escribiste. La encontré construyendo una UI de caché: esta es la historia, el código del framework y cómo la resolví."
date: "2026-06-11T22:38:15.000Z"
category: "Laravel"
seo_title: "La key fantasma de Cache::flexible() en Laravel"
seo_description: "Cache::flexible() guarda una clave interna que nunca escribiste. La encontré construyendo una UI de caché: esta es la historia, el código del framework y cómo la resolví."
author:
  name: "angel cruz"
  picture: "https://angelcruzdevcdn.nyc3.cdn.digitaloceanspaces.com/images/me/angel-cruz.png"
ogImage:
  url: "/images/open-graph/laravel-opengraph-image.png"
---


Hay bugs que rompen la build y se arreglan en cinco minutos. Y hay descubrimientos que no rompen nada (todo sigue "verde") pero que te obligan a abrir el código del framework para entender qué está pasando de verdad.

Mantengo un paquete pequeño, [`cache-ui-laravel`](https://github.com/abr4xas/cache-ui-laravel), cuya promesa es simple: **listar, buscar y borrar claves de caché individuales** sin tener que purgar toda la caché. Útil cuando estás depurando en local o limpiando una sola entrada en producción sin tirar abajo todo lo demás.

Mientras lo actualizaba a Laravel 13 me topé con una pregunta incómoda: ¿qué pasa con las features nuevas de caché? En concreto, con una que se ha vuelto muy popular: `Cache::flexible()`.

## Primero, ¿qué es `Cache::flexible()`?

Es la implementación de Laravel del patrón **stale-while-revalidate** (SWR), el mismo que usan los CDNs y los navegadores. La idea: en lugar de hacer esperar al usuario cuando la caché expira, le sirves el dato "viejo" (stale) inmediatamente y refrescas el valor **en segundo plano**, después de enviar la respuesta.

```php
$value = Cache::flexible('users', [5, 10], function () {
    return DB::table('users')->get();
});
```

Ese `[5, 10]` se lee así:

- **Primeros 5 segundos** → el dato está *fresco*. Se devuelve tal cual.
- **Entre el segundo 5 y el 10** → el dato está *stale*. Se devuelve igual, pero se registra una *deferred function* que recalcula el valor tras la respuesta.
- **A partir del segundo 10** → el dato está *expirado*. Se recalcula en el momento (y sí, ese request paga el coste).

Es una herramienta excelente para endpoints muy consultados cuyo cómputo es caro. Pero para poder decidir si un valor está fresco, stale o expirado, Laravel necesita saber **cuándo se creó**. Y ahí está el detalle.


## El descubrimiento

Mi paquete tiene un comando `cache:list` que muestra todas las claves. Al probar la compatibilidad con `flexible()`, escribí un test mínimo:

```php
it('DEMO: flexible() leaks an internal created key into the listing', function () {
    Config::set('cache.default', 'file');
    Config::set('cache.stores.file.driver', 'key-aware-file');

    Cache::store('file')->flexible('users', [5, 10], fn () => 'payload');

    $keys = app(CacheUiLaravel::class)->getAllKeys('file');

    dump($keys);
});
```

Esperaba ver una sola clave: `users`. Esto es lo que salió:

```bash
array:2 [
  0 => "users"
  1 => "illuminate:cache:flexible:created:users"
]
```

Una **segunda clave que yo nunca creé**. Aparecía en el listado como si fuera un dato de la aplicación, cuando en realidad es contabilidad interna del framework.


## A las fuentes: qué hace Laravel por debajo

La regla de oro al investigar algo así no es buscar en un blog (irónico, lo sé), sino ir al **código original**. Abrí `Illuminate\Cache\Repository` y ahí estaba, sin disimulo:

```php
// vendor/laravel/framework/src/Illuminate/Cache/Repository.php

const FLEXIBLE_CREATED_KEY_PREFIX = 'illuminate:cache:flexible:created:';
```

Y el método `flexible()` confirma para qué se usa:

```php
public function flexible($key, $ttl, $callback, $lock = null, $alwaysDefer = false)
{
    $key = enum_value($key);

    [
        $key => $value,
        self::FLEXIBLE_CREATED_KEY_PREFIX.$key => $created,
    ] = $this->many([$key, self::FLEXIBLE_CREATED_KEY_PREFIX.$key]);

    if (in_array(null, [$value, $created], true)) {
        return tap(value($callback), fn ($value) => $this->putMany([
            $key => $value,
            self::FLEXIBLE_CREATED_KEY_PREFIX.$key => Carbon::now()->getTimestamp(),
        ], $ttl[1]));
    }

    // ...
}
```

Traducido: **cada vez que guardas un valor con `flexible()`, Laravel guarda DOS entradas**: tu valor, y un timestamp en `illuminate:cache:flexible:created:<tu-key>`. Ese timestamp es lo que le permite calcular después si el dato está fresco o stale.

Es una decisión de diseño perfectamente razonable. El problema es que esa segunda clave vive en el **mismo keyspace** que tus datos. Para Redis, para la base de datos o para el sistema de ficheros, no hay diferencia entre `users` y `illuminate:cache:flexible:created:users`: ambas son claves de caché. Y por tanto ambas aparecen cuando listas.

> **Apunte interesante:** `flexible()` lee y escribe con `many()` y `putMany()`, no con `get()`/`put()` directos. Eso fue clave para mi paquete, porque tengo un store custom (`key-aware-file`) que envuelve los valores. Como `many()`/`putMany()` delegan internamente en `get()`/`put()`, todo el envoltorio funciona sin tocar nada. La compatibilidad estaba; el único síntoma era el ruido visual.


## La trampa de "no rompe nada"

Esto es lo que hace el caso interesante: **nada fallaba**. Los tests pasaban, la caché funcionaba, `flexible()` se comportaba de manual. El único síntoma era cosmético: un listado contaminado con claves internas que ningún humano escribió y que a nadie le importan.

Pero los síntomas cosméticos en herramientas de inspección no son inofensivos. Una UI de caché existe precisamente para darte una visión *fiel* de tu keyspace. Si la mitad de lo que muestra es ruido del framework, la herramienta miente un poco. Y una herramienta de depuración que miente es peor que no tener herramienta.


## La solución

La corrección fue deliberadamente pequeña. Filtré las claves internas conocidas **en el punto donde agrego el listado**, detrás de una opción de configuración por si alguien quiere verlas:

```php
final class CacheUiLaravel
{
    /**
     * Prefijo que Laravel usa para la entrada companion de Cache::flexible().
     *
     * @see \Illuminate\Cache\Repository::FLEXIBLE_CREATED_KEY_PREFIX
     */
    private const string FLEXIBLE_CREATED_KEY_PREFIX = 'illuminate:cache:flexible:created:';

    public function getAllKeys(?string $store = null, ?int $limit = null, int $offset = 0): array
    {
        // ... obtención de claves por driver (redis / file / database) ...

        if (config('cache-ui-laravel.hide_internal_keys', true)) {
            return $this->filterInternalKeys($keys);
        }

        return $keys;
    }

    /**
     * Elimina las claves internas de contabilidad de Laravel del listado.
     *
     * @param  array<string>  $keys
     * @return array<string>
     */
    private function filterInternalKeys(array $keys): array
    {
        return array_values(array_filter(
            $keys,
            static fn (string $key): bool => ! str_starts_with($key, self::FLEXIBLE_CREATED_KEY_PREFIX)
        ));
    }
}
```

Y la opción de configuración, con default sensato:

```php
// config/cache-ui-laravel.php

'hide_internal_keys' => env('CACHE_UI_HIDE_INTERNAL_KEYS', true),
```

Tres decisiones de diseño merecen explicación:

1. **El prefijo es una constante, no un literal suelto**, y apunta con `@see` al constante real del framework. Si Laravel lo renombra algún día, el rastro de migas está ahí.
2. **Es configurable.** Por defecto oculto el ruido, pero quien esté depurando el propio `flexible()` puede querer ver las claves internas. Decisión del usuario, no mía.
3. **El `limit` pasa a ser un tope "suave".** Como filtro *después* de pedir las claves al driver, una página de 100 podría devolver 99 visibles. Es un trade-off consciente y documentado: en una herramienta interactiva de búsqueda, vale más un listado honesto que un conteo exacto.

## Lo que decidí NO hacer

La primera tentación fue "limpiar bien": cuando borras la clave `users`, borrar también su _companion_ `illuminate:cache:flexible:created:users`. Lo hice... y lo
revertí. Por tres razones:

- Añadía un `forget()` extra en **cada** borrado, también para claves que nunca pasaron por `flexible()`.
- Acoplaba mi `forgetKey()` a un detalle interno del framework.
- **Es innecesario.** El "huérfano" se auto-sana: si borras el valor pero queda el timestamp, la siguiente llamada a `flexible()` ve el valor en `null`, entra por la rama de `in_array(null, ...)` y recalcula ambos. Además expira solo por su propio TTL. Y, sobre todo, ya queda oculto del listado por el filtro.

> La lección: cuando algo se auto-corrige y es invisible, "arreglarlo" suele ser añadir complejidad para resolver un problema que no existe.


## Conclusiones para llevarte

- **`Cache::flexible()` escribe dos entradas por clave.** Si listas, inspeccionas o cuentas claves de caché, ten en cuenta el prefijo `illuminate:cache:flexible:created:`.
- **El keyspace de caché es compartido y plano.** Redis, base de datos o ficheros no distinguen entre tus datos y la contabilidad del framework. Cualquier herramienta de inspección tiene que filtrar lo que no es del usuario.
- **Cuando algo te sorprenda, lee el framework, no un blog.** El código de `Illuminate` es legible y está a un `grep` de distancia en tu carpeta `vendor/`. La fuente primaria responde preguntas que ningún resumen de tercera mano puede.
- **No todo lo que descubres hay que "arreglarlo".** A veces la mejor corrección es la más pequeña, y a veces es ninguna.

> Un descubrimiento que no rompía nada terminó mejorando la honestidad de la herramienta y, de paso, me hizo entender mucho mejor cómo funciona una de las features más bonitas de la caché de Laravel. No está mal para una "key fantasma".


*¿Usas `Cache::flexible()` en producción? Échale un ojo a tu keyspace con un `SCAN` y
busca el prefijo `illuminate:cache:flexible:created:`. Te sorprenderá cuántas hay.*

---

## Sitemap

Índice completo del sitio: [/sitemap.md](https://www.angelcruz.dev/sitemap.md)

Canónico HTML: [https://www.angelcruz.dev/post/key-fantasma-de-cache-flexible](https://www.angelcruz.dev/post/key-fantasma-de-cache-flexible)
