Advanced

Method caching

#[Cacheable] remembers what a method returned: the first call runs the body, later calls with the same key get the stored value without a trip to the database. The set of attributes, their parameters and their behaviour come from Spring Cache.

Attributes #[Cacheable] · #[CachePut] · #[CacheEvict]Enabled by #[EnableCaching]Stores table · memory · file · redis

What this is and why

Method caching keeps what a method returned, so the next call with the same arguments gets the ready answer instead of computing it again.

The problem. Some data is read far more often than it changes: a list of countries, a product card, the settings of a plan. Every request still goes to the database for it, and the load grows with traffic although the answer is the same every time. A cache written by hand is a “is it cached” check, a write after the computation and a removal on change, smeared across every method of the service. A forgotten removal goes unnoticed until a user sees stale data.

The solution. Mark the method with an attribute, and the framework checks the cache, runs the body on a miss and stores the result. Removal on change is declared the same way, with an attribute on the method that changes the data.

Ancestry

Winter Spring counterpart
#[Cacheable] @Cacheable
#[CachePut] @CachePut
#[CacheEvict] @CacheEvict
#[CacheConfig] @CacheConfig
#[EnableCaching] @EnableCaching
CacheConfigurer a CacheManager bean

Where Winter differs from Spring

The key is a template, not an expression. Instead of SpEL, parameters are substituted: '{id}', '{filter.region}:{page}'. The template is read once, at build time, and a misspelt parameter name stops the build instead of turning into a key that matches nothing. There is no condition or unless.

The lifetime is set right here. In Spring a TTL is the concern of a particular store; in Winter it is the ttl parameter of the cache and of the attribute.

A call to a sibling method is cached too. In Spring @Cacheable does not apply when the method is called from the same class through this. In Winter the proxy is a subclass of your service, so a call through $this goes through the cache.

Every hit is a copy. The store keeps the serialized value, and each call gets its own instance. Changing an object taken from the cache and then seeing the change in another request is not possible.

Quick start

Three steps: enable it, declare a cache, mark a method.

bootstrap.php
use Flytachi\Winter\Kernel\App\Attribute\EnableCaching;
use Flytachi\Winter\Kernel\App\Attribute\EnableWeb;

#[EnableWeb]
#[EnableCaching]
final class Application extends WinterApplication
{
  public static function main(array $argv): never
  {
      parent::run($argv);
  }
}
main/Config/CacheConfig.php
namespace Main\Config;

use Flytachi\Winter\Kernel\Cache\CacheConfigurer;
use Flytachi\Winter\Kernel\Cache\Caches;

final class CacheConfig implements CacheConfigurer
{
  public function configureCaches(Caches $caches): void
  {
      $caches->table('countries');   // the defaults: 64 entries up to 16 KB, ≈1.6 MB
  }
}
main/Service/CountryService.php
namespace Main\Service;

use Flytachi\Winter\Kernel\Cache\Attribute\Cacheable;

class CountryService
{
  #[Cacheable('countries', key: '{code}')]
  public function find(string $code): ?Country
  {
      return $this->repository->find($code);   // runs once per code
  }
}

The service is injected as usual — the container hands out the cached version:

php
#[Inject]
private CountryService $countries;

$this->countries->find('UZ');   // the body ran, the result is stored
$this->countries->find('UZ');   // from the cache, no database query

Without `#[EnableCaching]` the attributes are silent

As in Spring, the attributes do nothing without the enabling one: the class is not proxied and every call runs the body. There is no error.

Stores

Each cache is declared in a CacheConfigurer and lives in one of four stores: table and memory in the server’s memory, file and redis outside it — so console commands see them and a restart does not wipe them.

table memory file redis
Where it lives a Swoole\Table in shared memory a PHP array in the worker files in storage/cache a Redis server
Server workers that see it all its own only all all
Seen by call … commands no no yes yes
Seen by other instances no no only with a shared disk yes
#[CacheEvict] in every worker in its own worker only in every worker in every instance
Restart and deploy loses it loses it survives survives
A read ~0.4 µs ~0.2 µs ~17 µs a network round trip
Memory reserved at startup grows as entries are written none none
Value size limited by valueSize any any any
What to configure rows, valueSize — or the defaults maxItems nothing the connection class
What it needs ext-swoole — — the winter-redis package

Which one to choose

Task Store Why
An external API token table One token for every worker and process: the auth service gets no extra requests, issue limits are not spent
A token that must survive a restart or is needed by call commands file It is on disk: a restart does not force a new one, a command sees the same token
Constants and settings from the database that change now and then table A removal after a change is seen by every worker at once
Settings changed by a console command file The command resets the cache, and the server sees it at once
Several instances of the application — replicas, servers redis The only store they all share: a removal on one server is seen by the rest
A reference list that does not change until the next deploy memory Nothing to plan, and a copy per worker gets in no one’s way
Small data that goes stale fast — rates, stock levels memory Expires on its own by ttl, shared memory is not needed
Thousands of entities by id table with rows and valueSize set Predictable memory and one cache for all
An application under FPM file or redis A process lives for one request — table and memory are useless there

In short: one server and changing data — table; must survive a restart or be seen from the console — file; several instances — redis; data that does not change or goes stale on its own by ttl — memory.

`#[CacheEvict]` on `memory` cleans its own worker only

The changing method ran in one worker — and the entry was removed in that one only. The other workers keep serving the old value until the ttl runs out. With one worker (the default) this goes unnoticed, but raise SERVER_WORKERS and users start seeing the new value one moment and the old one the next. For data that changes, use table, or give memory a short ttl.

table — shared by every process

The table is created in the master process before the workers are forked from it, which is why it is shared: a value computed by one worker is served by all the others at once, and a removal in one worker removes the entry for all. Processes beside the server, #[EnableProcess], #[EnableDaemon], #[EnableScheduler], see it too.

Parameter Default What it sets
rows 64 How many entries the table is guaranteed to hold
valueSize 16 * 1024 The largest serialized value, in bytes
ttl — Default lifetime of an entry, in seconds

The defaults are sized for the most common case — a handful of values: a token, settings, a short reference list. Such a table takes ≈1.6 MB.

By default there is one worker

Unless you changed SERVER_WORKERS, the server has one worker. Then the main gain of table over memory is not sharing between workers but that the cache survives a planned worker restart and is visible to the processes beside the server.

Ready-made table settings

main/Config/CacheConfig.php
// 1. A handful of values — a token, settings, a short list.  ≈1.6 MB
$caches->table('api-token');

// 2. A reference list per item: up to a thousand 2 KB entries. ≈3.2 MB
$caches->table('countries', rows: 1_024, valueSize: 2 * 1024);

// 3. Entities by id: ten thousand 2 KB cards.                 ≈51 MB
$caches->table('products', rows: 10_000, valueSize: 2 * 1024, ttl: 300);
Variant What it holds Memory
table('api-token') up to 64 values of 16 KB ≈1.6 MB
rows: 1_024, valueSize: 2 * 1024 up to a thousand entries of 2 KB ≈3.2 MB
rows: 10_000, valueSize: 2 * 1024 up to ten thousand entries of 2 KB ≈51 MB

The memory figures are measured on a live process. What matters is not how many entries there will be but how many there may become. When the table fills up, the entry is not stored, and a warning appears in the log.

What fits in a value

valueSize limits the value after serialize(). Measurements, so there is no guessing:

Value Size In 16 KB
an OAuth token, 40 characters 48 B ✅
a JWT, ~800 characters 0.8 KB ✅
a whole OAuth response: token, expiry, type, scope 0.9 KB ✅
20 settings name => value 0.9 KB ✅
one entity — 7 fields, a ~120-character description 0.35 KB ✅
a list of 10 such entities 3.4 KB ✅
a list of 45 entities 15.4 KB ✅
a list of 50 entities 17.1 KB ❌
100 countries code => name 3.2 KB ✅
450 countries code => name 15.2 KB ✅

A rule of thumb: one entity record takes about 350 bytes, and an object and an array of the same shape weigh almost the same. The exact number for your own data:

php
echo strlen(serialize($service->all()));   // bytes — compare with valueSize

How much memory a table takes

The memory is reserved in full at startup — however many entries appear later. Swoole rounds the number of rows up to a power of two and adds room for collisions, so the estimate is:

text
power of two ≥ rows  ×  1.5  ×  (valueSize + 120) bytes

rows: 64,     valueSize: 16384  →     64 × 1.5 × 16504  ≈ 1.6 MB
rows: 10_000, valueSize: 4096   →  16 384 × 1.5 × 4216   ≈ 99 MB

The total shows in the startup banner:

text
   web         http://0.0.0.0:8000
 cache       2 table · 1 memory · ≈53 MB shared

When a value does not fit

A value longer than valueSize, or a write into a full table, is not stored: the method still returns the right result, just without the cache. Swoole itself silently truncates a string that is too long, and a truncated value can no longer be read — so such a write never reaches the table, and the old entry under the same key is removed so nothing stale is served.

Before refusing, the table drops its expired entries once. If there is still no room, a warning is logged — at most once a minute per cache, with a count of skipped writes:

text
[WARN ] Cache 'settings' skipped a write: a value of 21480 bytes does not fit valueSize 16384;
raise valueSize in the CacheConfigurer. The value was returned uncached; 12 write(s) skipped
since the last warning.

Such a message means the cache stopped working for part of the data and the load on the database came back. Raise what the message names: valueSize or rows.

memory — one per worker

A plain PHP array: nothing is reserved up front, and a value of any size fits. The price is that every worker has its own copy and computes it itself.

Parameter Default What it sets
maxItems 1000 How many entries each worker keeps
ttl — Default lifetime of an entry, in seconds

When there are more than maxItems entries, the one read least recently goes. That is ordinary work for such a cache, and it logs no warnings.

Memory grows as entries are written: each entry costs its value plus about 370 bytes. A thousand entities of ~350 bytes is about 0.7 MB per worker, five thousand about 3.4 MB.

Ready-made memory settings

main/Config/CacheConfig.php
// 1. A reference list that does not change until a deploy: lives as long as the worker.
$caches->memory('currencies');

// 2. Rates: a hundred pairs, each lives a minute.
$caches->memory('rates', maxItems: 100, ttl: 60);

// 3. Stock levels: thousands of items, half a minute.
$caches->memory('stock', maxItems: 5_000, ttl: 30);
Variant What for Memory per worker
memory('currencies') the data does not change, nothing to remove as it fills, up to 1000 entries
maxItems: 100, ttl: 60 the value goes stale fast and on its own under 0.1 MB
maxItems: 5_000, ttl: 30 many small hot entries up to ~3.4 MB

file — on disk

Entries are files in storage/cache/winter-cache/<name>/. Every worker and process reads them, call commands see them, and a restart or a deploy does not wipe them. A write is atomic — a temporary file and a rename — so no one ever reads half a value.

Parameter Default What it sets
ttl — Default lifetime of an entry, in seconds
version — Generation of the entries — a subfolder; change it and older entries are not found

A read takes about 17 µs, a write about 150 µs: dozens of times slower than table, but hundreds of times faster than a database query. Expired files go when read and in one sweep at server start; call storage clean empties the folder as well.

main/Config/CacheConfig.php
// A token that survives a restart and is seen by call commands.
$caches->file('api-token', ttl: 3300);

// Settings a console command resets.
$caches->file('settings', version: 'v2');

Under FPM, where a process lives for one request, this store still caches.

redis — shared by every instance

The only store several instances of the application share: replicas in Kubernetes, servers behind a load balancer. A removal on one server is seen by all of them. It needs the winter-redis package — without it a redis() declaration stops the start with a hint on what to install.

main/Config/CacheConfig.php
$caches->redis('products', MainRedisConfig::class, ttl: 300);
Parameter Default What it sets
config — The connection class — the same one winter-redis stores use
ttl — Lifetime of an entry, in seconds; Redis expires it itself
prefix cache:<name>: The key prefix; set your own if several applications share one database
version — Generation of the entries — part of the prefix

Keys in Redis are visible as they are: #[Cacheable('products', key: '{id}')] with version v2 writes to cache:products:v2:42. allEntries and call cache clear remove only keys with this cache’s prefix — the rest of the database is left alone.

When a store is unreachable

A broken cache must not break the application. If Redis does not answer or a file cannot be written:

  • a read counts as a miss — the method runs and returns the right result;
  • a write is skipped;
  • a warning is logged — at most once a minute per cache, with a count;
  • the worker leaves the store alone for 5 seconds and runs without the cache, then tries again.

The pause matters. An unreachable Redis does not always refuse at once: an attempt may wait out the connect timeout — about three seconds with the winter-redis defaults. Without the pause every call would pay that twice, to read and to write, and an application that “did not fall over” would in fact stand still.

A failed removal is an `error`

If #[CacheEvict] could not remove an entry, it stays stale until its ttl runs out — users see old data. That is why such a failure is logged as error, not warning. Keep a ttl on data in Redis: it bounds how long an entry that could not be removed lives on.

Attributes

#[Cacheable] — return from the cache or compute

php
#[Cacheable('products', key: '{id}', ttl: 300)]
public function find(int $id): ?Product
Parameter Default What it sets
cache from #[CacheConfig] The cache name; the first positional argument
key every argument Key template
ttl from #[CacheConfig], then the cache’s Lifetime in seconds; 0 never expires
sync false Concurrent misses on one key wait for one computation
cacheNull true Whether null is stored

If the entry exists, the body does not run. If not, it runs and the result is stored. An exception from the body is not cached: the next call tries again.

#[CachePut] — always run and refresh

For methods that change data and return the new state. The body always runs, and the result is written to the cache — the next read finds it.

php
#[CachePut('products', key: '{result.id}')]
public function save(Product $product): Product

The parameters are those of #[Cacheable], except sync. The key may use {result} — the returned value. With cacheNull: false a null result removes the entry instead of keeping the old one.

#[CacheEvict] — remove

php
#[CacheEvict('products', key: '{id}')]
#[CacheEvict('product-lists', allEntries: true)]
public function delete(int $id): void
Parameter Default What it sets
cache from #[CacheConfig] The cache name
key every argument Which entry to remove
allEntries false Empty the whole cache
beforeInvocation false Remove before the body rather than after

The attribute is repeatable: one method can clean several caches. It combines with #[Cacheable] or #[CachePut] on the same method.

By default the entry is removed after the body completes successfully. If the body throws, the change did not happen and the entry stays — it is still valid. With beforeInvocation: true the entry is removed before the call, whatever happens next.

#[CacheConfig] — class defaults

php
#[CacheConfig('products', ttl: 300)]
class ProductService
{
  #[Cacheable(key: '{id}')]                 // cache products, 300 seconds
  public function find(int $id): ?Product

  #[Cacheable(key: 'all', ttl: 60)]         // cache products, 60 seconds
  public function all(): array
}

A value on the method always wins over the one on the class.

Keys

The key decides which entry a call belongs to. It is a template over the method’s parameter names:

Template Key for the call
'{id}' find(42) → 42
'product:{id}' find(42) → product:42
'{category}:{page}' list('books', 2) → books:2
'{filter.region}' a public property of an object parameter
'{result.id}' the returned value — only in #[CachePut] and in #[CacheEvict] after the body
'all' a constant key — one entry per method

A key can hold strings, numbers, bool, null, enums, arrays and objects with __toString(). A whole object does not go into a key — name its property: '{order.id}'.

Without key the key is built from every argument in order. A method without parameters has a constant key — a single entry.

The method name is not part of the key

Two methods of one cache with the same key share an entry. That is deliberate: it is how #[CachePut] and #[CacheEvict] on other methods reach the entry #[Cacheable] stored. But keep two methods that mean different things apart with a prefix: 'by-id:{id}' and 'by-sku:{sku}'.

Lifetime

ttl is in seconds. The value comes from the first level that sets one:

  1. the attribute parameter on the method;
  2. #[CacheConfig] on the class;
  3. the cache’s ttl in the CacheConfigurer;
  4. none — the entry lives until it is removed or the application restarts.

ttl: 0 means “never expires” and overrides the level above — this is how one method of a class with #[CacheConfig(ttl: 60)] is made permanent. An expired entry counts as a miss when read: the method runs again and its result overwrites the entry.

null is cached too

By default, as in Spring, null is stored like any other value: “there is no such record” is an answer too, and the database should not get the same empty query on every call.

The consequence: if the record appears later, the cache keeps returning null until it is removed. The method that creates it has to tell the cache:

php
#[Cacheable('users', key: '{email}')]
public function findByEmail(string $email): ?User

#[CacheEvict('users', key: '{user.email}')]
public function register(User $user): User

If that is inconvenient, turn off storing an empty result: cacheNull: false.

sync — one computation per miss

When an entry expires under load, dozens of requests miss at once and go to the database for the same thing simultaneously. With sync: true the first request computes the value, the others wait for it and get the ready result.

php
#[Cacheable('reports', key: 'daily', ttl: 600, sync: true)]
public function dailyReport(): Report

Waiting happens within one worker

Coroutines of one worker wait for each other. With several workers, each computes the value itself — that is, at most one computation per worker, not one per server.

Build-time checks

Everything that can be checked from the declaration is checked by call di build and at application startup — not on the first call in production:

Error Sample message
The cache is not declared undeclared cache 'prodcuts' used by ProductService::find()
The attribute names no cache #[Cacheable] names no cache
An unknown parameter in the template key refers to {idd}, but the method has no parameter $idd
A parameter cannot become a key parameter $order of type Order cannot be part of one
Nothing to store #[Cacheable] needs a value to store, but the method returns void
A by-reference parameter parameter $rows is passed by reference
#[Async] and a cache on one method the method carries both #[Async] and #[Cacheable]/…

A misspelt cache name on #[CacheEvict] is the dangerous one: such a method would simply remove nothing, and users would see stale data. That is why the name is checked before startup.

The error shows with `DEBUG=false` too

A failed start always writes one line to stderr — docker logs shows it — and exits with code 1; with logging on, the same error goes to the log as CRIT. The stack trace is added only with DEBUG=true.

Class and method requirements

The same as for #[Async], because the same mechanism is at work — a generated subclass:

  • the class is not final; the method is not final, static or private;
  • the service comes from the container, not from new — otherwise the proxy does not apply, and call di build warns about it;
  • #[Cacheable] and #[CachePut] sit on a method that returns a storable value: not void, never, Generator, Closure, callable or iterable;
  • the value has to pass through serialize(): a closure inside an object will not be stored — the method returns it uncached and logs a warning.

One class can mix #[Async] methods and cached methods — it gets one shared proxy. They cannot be combined on one method.

What the cache does not see

  • Console commands — table and memory. call … is a separate process with its own memory: a command that changes data will not clear such a cache of the server. If you need that, use file or redis — both a #[CacheEvict] in the command and call cache clear reach them.
  • Other instances of the application — everything but redis. Each container holds its own table and its own folder; a change in one does not remove the entry in another.
  • FPM — table and memory. A process lives for one request, and such a cache with it.

Deploys and old data

table and memory are empty after a restart. file and redis survive a deploy — and with it the entries written by the old code. If the class of a value changed, say a new typed property appeared, the object from such an entry comes back incomplete.

Three measures deal with it:

  • an entry that no longer restores — a class that is gone, corrupted bytes — counts as a miss: it is removed and computed again;
  • version on the cache — change it and older entries are simply not found: in redis they expire on their own, in file the sweep removes them;
  • call cache clear — clear the cache explicitly, for instance in a deploy script.
sh
php call cache clear --all   # every file and redis cache
php call run

Only the top level is checked

An incomplete object nested in an array is not caught: from outside the value restored. If you change classes that sit in the cache inside lists, change the version or clear the cache on deploy.

Examples

An external API token for an hour

The most common case. The auth service issues a token for an hour, and requesting it on every call is an extra delay and a risk of hitting the issue limit.

main/Service/PaymentGateway.php
use Flytachi\Winter\Kernel\Cache\Attribute\CacheEvict;
use Flytachi\Winter\Kernel\Cache\Attribute\Cacheable;

class PaymentGateway
{
  #[Cacheable('api-token', key: 'payment', ttl: 3300, sync: true, cacheNull: false)]
  public function token(): string
  {
      $response = $this->http->post('https://auth.example.com/oauth/token', [
          'client_id'     => $this->clientId,
          'client_secret' => $this->clientSecret,
          'grant_type'    => 'client_credentials',
      ]);

      return $response['access_token'];
  }

  #[CacheEvict('api-token', key: 'payment')]
  public function forgetToken(): void
  {
  }

  public function charge(Order $order): array
  {
      $response = $this->send($order, $this->token());

      // The token was revoked early: forget it, get a new one, retry once.
      if ($response['status'] === 401) {
          $this->forgetToken();
          $response = $this->send($order, $this->token());
      }

      return $response;
  }
}
main/Config/CacheConfig.php
$caches->table('api-token');   // the defaults: ≈1.6 MB, room for dozens of tokens

What matters here:

  • ttl: 3300 — 55 minutes for an hour-long token. The margin keeps the cache from handing out a token with a second left: a request with it goes out and comes back 401.
  • sync: true — when the token expires, dozens of simultaneous requests do not go for a new one at once: one gets it, the others wait for it.
  • cacheNull: false — if the auth service returned an empty response, it is not remembered for 55 minutes. An exception is not cached in any case.
  • key: 'payment' — one api-token cache can hold the tokens of several services under different keys.
  • $this->token() inside charge() goes through the cache: the proxy is a subclass of the service, so a call to its own method is cached too.
  • table, not memory: one token for every worker and process, and forgetToken() resets it for all of them at once. To survive a restart — file('api-token'); with several instances — redis('api-token', …). The attributes stay the same.

Constants from the database

Settings, tariffs, texts — read on every request, changed once a week.

main/Service/SettingsService.php
#[CacheConfig('settings')]
class SettingsService
{
  #[Cacheable(key: 'all')]
  public function all(): array
  {
      return $this->repository->pairs();   // ['name' => 'value', ...]
  }

  public function get(string $name, ?string $default = null): ?string
  {
      return $this->all()[$name] ?? $default;   // from the cache
  }

  #[CacheEvict(key: 'all')]
  public function set(string $name, string $value): void
  {
      $this->repository->save($name, $value);
  }
}
main/Config/CacheConfig.php
$caches->table('settings');   // up to 16 KB of settings — hundreds of pairs

All the settings are one value under the key 'all': one database query for everyone, and set() resets it in every worker at once. No ttl is needed — the entry lives until it is changed. If the settings are changed bypassing the service (by hand in the database), add a ttl, say 600: the change is then picked up within ten minutes.

A reference list

The data hardly ever changes, the key is a code, there is nothing to remove.

main/Service/CurrencyService.php
#[CacheConfig('currencies')]
class CurrencyService
{
  #[Cacheable(key: '{code}')]
  public function find(string $code): ?Currency
  {
      return $this->repository->find($code);
  }

  #[Cacheable(key: 'all')]
  public function all(): array
  {
      return $this->repository->findAll();
  }
}
main/Config/CacheConfig.php
$caches->memory('currencies');   // changes only with a deploy — memory is enough

An entity: read, change, remove

main/Service/ProductService.php
#[CacheConfig('products', ttl: 300)]
class ProductService
{
  #[Cacheable(key: '{id}')]
  public function find(int $id): ?Product
  {
      return $this->repository->find($id);
  }

  // The new state goes straight into the cache, the lists are reset.
  #[CachePut(key: '{result.id}')]
  #[CacheEvict('product-lists', allEntries: true)]
  public function save(Product $product): Product
  {
      return $this->repository->save($product);
  }

  #[CacheEvict(key: '{id}')]
  #[CacheEvict('product-lists', allEntries: true)]
  public function delete(int $id): void
  {
      $this->repository->delete($id);
  }

  #[Cacheable('product-lists', key: '{category}:{page}', ttl: 60)]
  public function byCategory(string $category, int $page = 1): array
  {
      return $this->repository->byCategory($category, $page);
  }
}
main/Config/CacheConfig.php
$caches
  ->table('products', rows: 10_000, valueSize: 2 * 1024)         // ≈51 MB
  ->table('product-lists', rows: 512, valueSize: 16 * 1024);     // a page of up to 45 cards, ≈12 MB

Stock levels — change often, go stale fast

The value lives half a minute, no shared memory needed: each worker keeps its copy.

php
#[Cacheable('stock', key: '{sku}', ttl: 30)]
public function available(string $sku): int

// CacheConfig:
$caches->memory('stock', maxItems: 5_000);

Next