--- title: Caching description: Cache values with TTLs through cooler.Cache, in process or shared through Redis, under a per-application key prefix. section: services order: 45 --- # Caching WinterCMS and Laravel cache through the `Cache` facade: `Cache::get`, `Cache::put`, `Cache::remember` and friends, backed by the store `config/cache.php` selects. SummerCMS has the same core through [cooler](../../modules/cooler/README.md): one `cooler.Cache` per application, with an in-process driver by default and a Redis driver shared by every instance. ## The Cache API | Laravel | cooler | |---------|--------| | `Cache::get($key)` | `cooler.Cache.Get` returns the bytes and whether the key was found. | | `Cache::put($key, $value, $ttl)` | `cooler.Cache.Set` stores bytes for a TTL. | | `Cache::forever($key, $value)` | `cooler.Cache.Set` with a TTL of 0. | | `Cache::has($key)` | `cooler.Cache.Has`. | | `Cache::forget($key)` | `cooler.Cache.Forget`. | | `Cache::remember($key, $ttl, $callback)` | `cooler.Cache.Remember`. | | `Cache::increment($key, $by)` | `cooler.Cache.Increment`. | | `Cache::flush()` | `cooler.Cache.Flush`, limited to this application's cache keys. | Values are bytes, so encode structured values yourself, for example with `encoding/json`. There are no tags and no locks. A plugin looks the cache up on the application with `app.Lookup[cooler.Cache]()`, or asks `cooler.FromApp` for it. Here the second `cooler.Cache.Remember` call is answered from the cache: ```go src=modules/cooler/example_test.go#ExampleFromApp // serve publishes the Cache at start-up; a job or console command asks // FromApp, which builds the configured driver (memory here) once. app := backpack.New(nil) cache, err := cooler.FromApp(app) if err != nil { fmt.Println(err) return } ctx := context.Background() loads := 0 popular := func(context.Context) ([]byte, error) { loads++ return json.Marshal([]string{"Hello", "Summer"}) } var titles []string for range 2 { raw, err := cache.Remember(ctx, "blog:popular", 10*time.Minute, popular) if err != nil { fmt.Println(err) return } if err := json.Unmarshal(raw, &titles); err != nil { fmt.Println(err) return } } fmt.Println(titles, loads) // Output: [Hello Summer] 1 ``` ## Drivers `cache.driver` selects the driver: - `memory` (the default) keeps values in the process, in an otter cache bounded by `cache.memory.max_entries` (default 10000; `cooler.DefaultMaxEntries`). Beyond that, the least valuable entries are evicted, so keys derived from request data cannot grow memory without limit. Each instance has its own memory cache. - `redis` keeps values in Redis at `redis.url`, shared by every instance. When the rate limiter also uses Redis (`http.rate_limit.store: redis`, see [Rate limiting](rate-limiting.md#sharing-limits-across-instances)), both use one connection pool. ```yaml # config/cache.yaml driver: redis ``` ```yaml # config/redis.yaml (or SUMMER_REDIS__URL, which keeps a password out of YAML) url: redis://10.0.0.7:6379/0 ``` A missing or malformed `redis.url`, an unknown driver name or a `cache.memory.max_entries` below 1 fails the start-up with an error that names the key; an error never shows the Redis URL. Redis being down at start-up is a warning, not a failed start. ## Keys and the prefix Every key is stored as `cache:`. The prefix is `cache.prefix` when set; by default it is `app.name` lower-cased, with every run of characters other than letters and digits replaced by `_`, followed by `:`, so `app.name: Acme Blog` gives `acme_blog:` and the key `blog:popular` is stored as `acme_blog:cache:blog:popular`. With `app.name` empty the prefix is `summer:`. Rate-limit counters live under `rl:`, so several applications, and the cache and the limiter of one application, can share a Redis without touching each other's keys. ## TTLs A TTL of 0 or less keeps a value until it is forgotten, flushed or, in the memory driver, evicted. A positive TTL expires the value after that long; Redis stores it with millisecond precision. `cooler.Cache.Increment` starts a missing key at 0 without a TTL and keeps the expiry of an existing key, as Redis `INCRBY` does. ## Remember degrades instead of failing `cooler.Cache.Remember` reads the key and, on a miss, calls the callback, stores its value for the TTL and returns it. When the cache itself fails, for example while Redis is down, it does not fail the page: a failed read still calls the callback and returns its value, a failed write is skipped, and both are logged as a warning at most once per minute. Only an error from the callback is returned. The other methods return cache errors to the caller. ## Jobs and console commands The `serve` command installs cooler at start-up (`cooler.Install`) and publishes the cache on the application, so plugins and the in-process job worker find it with `app.Lookup[cooler.Cache]()`. Other commands, such as a dedicated `queue:work` process or a console command, do not run `serve`; they call `cooler.FromApp`, which returns the published cache or builds the configured one and publishes it. It does not connect at construction: the Redis driver dials on the first call, so a command that never touches the cache never needs Redis. ## Flush `cooler.Cache.Flush` removes this application's cache keys and nothing else. The Redis driver walks `SCAN MATCH cache:*` in batches of 500 and removes each batch with `UNLINK`; it never empties the whole Redis database, which would also drop rate-limit counters and other applications' keys. The memory driver empties the process cache. ## Site cache purge A server-rendered site in front of the API usually caches its pages for a few minutes (a Nuxt `routeRules` `swr` window, for example). Without help, an edit in the admin shows on the site only when that window ends. cooler closes the gap: after an admin save commits, it queues a purge request to the site, and the site drops its route cache, so the next page view renders the change. Configure the target once for the application: | Key | Environment | Meaning | |-----|-------------|---------| | `site_cache.purge_url` | `SUMMER_SITE_CACHE__PURGE_URL` | The site's purge route, for example `http://127.0.0.1:3000/api/__site__/cache/purge`. | | `site_cache.purge_token` | `SUMMER_SITE_CACHE__PURGE_TOKEN` | The bearer token, at least 32 characters (`cooler.SiteCachePurgeMinTokenLength`). | Set both or neither; with neither, nothing is purged. The URL must be http or https with a host, without user info or a fragment, and plain http is accepted only for a loopback host (127.0.0.0/8, `::1`, `localhost`): the site normally runs on the same machine, and the token must not cross a network in clear. Use https for any other host. A wrong or half-set configuration stops `serve` at start-up with an error that names the key; neither errors nor log lines ever contain the token. ### What a purge sends Each committed save queues a purge; the request looks like this: ```http POST /api/__site__/cache/purge Authorization: Bearer Content-Type: application/json {"kind":"faq","slug":"shipping"} ``` The kind names what was saved (a plugin picks it, for example `faq`, `menu`, `menu_entry`, `post` or `category`) and the slug which record; the site may log them. The site answers `{"purged": }`, and cooler logs `cooler: site cache purged` with the kind, slug, status and count. At most one request is in flight per process (per purger). Saves committed while it is sent collapse into one follow-up request carrying the last kind and slug, because each purge clears the site's whole route cache: a bulk delete of fifty rows sends at most two requests. The log line of such a follow-up adds `coalesced` with the number of saves it stands in for besides its own. ### Timing and failures `cooler.PurgeSiteCacheAfterCommit` registers the purge on the save's transaction through `lagoon.AfterCommit`. It is queued only after the commit, so a page view between the purge and the commit cannot re-cache the old content, and a save that fails or is rolled back sends nothing. Outside a transaction it is queued at once. Queuing never blocks: the admin request answers without waiting for the site, and a background worker sends the request. A purge never fails a save. Each request is bounded by `cooler.SiteCachePurgeTimeout` (2 s) and detached from the admin request's cancellation; an unreachable site, an error status or a timeout is logged as one `cooler: site cache purge failed` warning and the save answers as usual. Redirects are not followed: a 3xx answer is logged as a failed purge with its status, so the bearer token never goes to another URL and the POST never turns into a GET. Point `site_cache.purge_url` at the final URL. Because the purge is fire and forget, a test or a console command that wants to see its effect calls `cooler.SiteCachePurger.Wait`, which returns once nothing is queued or in flight (or when its context ends). `serve` waits for a queued purge on shutdown, up to the timeout plus one second, through `cooler.Handle.Close`. ### Purging from a plugin The `serve` command publishes the purger at start-up (`cooler.Install`), and `cooler.SiteCacheFromApp` returns it, or builds it from the keys in commands and tests that do not run `serve`. An admin controller calls it from its after-save hooks, which run inside the save transaction, and takes that transaction from `cabana.TxFromContext`: ```go src=modules/cooler/example_test.go#articlesController.purge // purge queues one site-cache purge for the saved article once the save // transaction commits (at once outside one); the save never waits for the // site. Without a purge configured the purger is nil and nothing is sent. func (c articlesController) purge(ctx context.Context, model any) error { article, ok := model.(*Article) if !ok { return nil } tx, _ := cabana.TxFromContext(ctx) cooler.PurgeSiteCacheAfterCommit(ctx, tx, cooler.SiteCacheFromApp(c.app()), "article", article.Slug) return nil } ``` Each of `pact.FormAfterCreate`, `pact.FormAfterUpdate` and `pact.FormAfterDelete` calls it: ```go src=modules/cooler/example_test.go#articlesController.FormAfterUpdate // FormAfterUpdate runs inside the update's transaction. func (c articlesController) FormAfterUpdate(ctx context.Context, model any) error { return c.purge(ctx, model) } ``` A nil purger (no purge configured) does nothing, so the hooks need no configuration check. A tree or sortable list's reorder save runs `pact.ReorderAfterSave` the same way; see [Tree reorder](../backend/lists-and-filters.md#tree-reorder). ### The site side The Nuxt layers of the core plugins (the Journal, menu and FAQ layers) each ship the same route, so a site that extends any of them has it: - `POST /api/__site__/cache/purge` removes every entry of Nitro's route cache (pages, their `_payload.json` and feeds) and answers `{"purged": }`. - Its token is `runtimeConfig.siteCache.purgeToken`, set through `NUXT_SITE_CACHE_PURGE_TOKEN` to the same value as `site_cache.purge_token`. With a token shorter than 32 characters the route answers 404. - It answers 404 to any request that came through a reverse proxy (`X-Forwarded-For`, `X-Real-IP` or `Forwarded`), unless the site opts in with `NUXT_SITE_CACHE_ALLOW_PROXIED` (see [A purge URL on another host](#a-purge-url-on-another-host)), and 401 to a missing or wrong bearer token, compared in constant time. - Each layer also ships the route rule `'/api/__site__/**': { cache: false }`. Without it, a site rule such as `'/**': { swr: 300 }` would wrap the purge route in Nitro's cached handler, which answers from the cache without running the route. The three copies are byte-identical, so it does not matter which layer's copy Nitro picks. ### A purge URL on another host When the site does not run on the backend's machine, `site_cache.purge_url` is an https URL on the site's host, for example `https://www.example.com/api/__site__/cache/purge`. The request then reaches Nuxt through the site's TLS-terminating proxy (nginx, Caddy, Traefik), which adds `X-Forwarded-For`, `X-Real-IP` or `Forwarded`, and the route refuses such requests by default with 404. Pair the https purge URL with `NUXT_SITE_CACHE_ALLOW_PROXIED=true` on the site (`runtimeConfig.siteCache.allowProxied`): the route then accepts proxied requests, and the bearer token is the only gate. Keep the token long and secret (the same value in `site_cache.purge_token` and `NUXT_SITE_CACHE_PURGE_TOKEN`), and keep the default, loopback and no flag, whenever the site runs next to the backend.