Verified against Semitexa Ultimate 2026.10.20.0340
Cache
semitexa/cache keeps a computed value for a while so the next request does not compute it again. It is installed in every project. Use it for results that are expensive to build and stay valid for at least a few seconds; skip it for values that change on every read or cost less to recompute than to look up.
Getting it
Ask for the interface with #[InjectAsReadonly]. You get the worker's one shared instance.
use Semitexa\Cache\Domain\Contract\CacheManagerInterface;
use Semitexa\Core\Attribute\AsService;
use Semitexa\Core\Attribute\InjectAsReadonly;
#[AsService]
final class ProductCatalog
{
#[InjectAsReadonly]
protected CacheManagerInterface $cache;
public function featured(): array
{
return $this->cache->remember(
'featured-products',
fn (): array => $this->loadFeatured(), // runs only on a miss
ttlSeconds: 600,
tags: ['products'],
);
}
}
The whole contract:
get(string $key, mixed $default = null): mixed
put(string $key, mixed $value, ?int $ttlSeconds = null, array $tags = []): void
remember(string $key, callable $resolver, ?int $ttlSeconds = null, array $tags = []): mixed
forget(string $key): void
flushTags(string ...$tags): int
flushNamespace(?string $namespace = null): int
withNamespace(string $namespace): CacheManagerInterface
withTags(string ...$tags): CacheManagerInterface
scope(CacheScope $scope): CacheManagerInterface
Choosing the store
CACHE_DRIVER picks the store. Only two values are accepted, exactly as written; anything else (Redis, memcached) stops the cache with an InvalidArgumentException on first use.
CACHE_DRIVER |
Store | Seen by |
|---|---|---|
array (default) |
a PHP array inside the worker | that worker only, until it restarts |
redis |
Redis | every worker and every server on the same Redis |
With redis, the cache borrows connections from the worker's shared Redis pool (REDIS_HOST, REDIS_PORT, REDIS_SCHEME, REDIS_PASSWORD). Set CACHE_REDIS_POOL_SIZE and the cache opens its own pool of that size instead. Each operation borrows one connection for its whole duration, so concurrent coroutines never share a socket.
bin/semitexa system:doctor (check cache.driver) fails on an invalid driver and warns when CACHE_DRIVER=array runs with APP_ENV=prod: features that must agree across workers, such as UI event replay protection, refuse to run on a per-worker cache.
Other settings:
| Variable | Default | Effect |
|---|---|---|
CACHE_DEFAULT_TTL |
300 |
seconds, used when a call passes no TTL |
CACHE_ALLOW_FOREVER |
0 |
1 allows ttlSeconds: 0 (never expires) |
CACHE_TAGS_ENABLED |
1 |
0 ignores tags: nothing is indexed, flushTags() removes nothing and returns 0 |
CACHE_PREFIX |
semitexa |
first segment of every key |
CACHE_APP |
APP_NAME, else app |
second segment |
CACHE_ENV |
APP_ENV, else prod |
third segment |
CACHE_SIGNING_KEY |
unset | allows caching objects (see below) |
Two applications on one Redis stay apart as long as their prefix, app or env differs.
Reading and writing
$this->cache->put('rates:eur', $rates, ttlSeconds: 60);
$rates = $this->cache->get('rates:eur', []); // [] on a miss
$this->cache->forget('rates:eur');
- Keys: any non-empty string without a NUL byte. Colons are fine.
- TTL:
nullmeansCACHE_DEFAULT_TTL. A negative TTL throws.0means forever and throws unlessCACHE_ALLOW_FOREVER=1. An expired entry reads as a miss and is deleted when it is read. - Values: scalars,
null, and arrays of those are stored as JSON. An object anywhere in the value — aDateTimeImmutable, an enum, a DTO inside an array — needsCACHE_SIGNING_KEY; without itput()throwsCacheSerializationException. With it, the value is PHP-serialized and HMAC-signed, and a value whose signature does not match is refused on read. Rotating the key turns existing object entries into misses. - A stored
nulland a miss look alike toget($key): pass a default you can recognise (get($key, $missing)) when the difference matters.
remember() and concurrent misses
remember() returns the cached value, or calls the resolver, stores what it returns, and returns it.
Inside a Swoole worker it is single-flight per key. When many coroutines miss the same key at once, the first one runs the resolver; the others wait for it and then read the value it stored. A burst of 8 concurrent misses calls the resolver once (CacheRememberSingleFlightTest). Different keys resolve independently.
The guarantee has edges:
-
Per worker. Four workers can each run the resolver once for the same key.
-
Per manager object. The in-flight list belongs to the instance you call.
withNamespace(),withTags()andscope()build a new object on every call, so keep the scoped manager in a property instead of rebuilding it per call:private ?CacheManagerInterface $views = null; private function views(): CacheManagerInterface { return $this->views ??= $this->cache->withNamespace('views'); } -
nullis cached. A resolver that returnsnullis not run again until the entry expires. -
Exceptions. If the leader's resolver throws, the exception reaches the leader's caller; each waiter then runs the resolver itself.
-
Outside a coroutine (CLI, tests) there is nothing to coalesce: the resolver simply runs on a miss.
-
A concurrent
put()orforget()from elsewhere can still race the leader's write.
Namespaces
A namespace groups keys so they can be cleared together.
$views = $this->cache->withNamespace('views');
$views->put('home', $html, ttlSeconds: 120);
$views->flushNamespace(); // clears 'views' only
$this->cache->flushNamespace(); // clears the root AND every namespace of this tenant
- A name may contain only letters, digits,
-and_; anything else throws. - Root key
views:homeand keyhomein namespaceviewsare two different entries. Forgetting or flushing one does not touch the other. - Clearing a named namespace leaves the root and sibling namespaces alone. Clearing the root clears the whole tenant.
flushNamespace()returns the number of entries removed. On Redis it walks the keys withSCAN.
Tags
A tag names a group of entries across keys. Flush the tag to remove all of them.
$this->cache->put('product:42', $p42, tags: ['products', 'product:42']);
$this->cache->put('product:43', $p43, tags: ['products']);
$this->cache->flushTags('product:42'); // removes product:42 → 1
$this->cache->flushTags('products', 'categories'); // every entry carrying either tag
What a flush removes (CacheTagMembershipTest, RedisTagFlushTest):
- The tags an entry carries now. An entry rewritten with different tags, or with none, is not removed by a flush of its old tag.
- Any one of its tags is enough. An entry written with
['one', 'two']goes with a flush oftwo. - The count is what was actually deleted. An entry already forgotten or expired is not counted. Writing the same key under the same tag three times counts once.
- One namespace, one scope. A flush of
sharedon the root does not reachwithNamespace('views'), and the other way round. Each namespace is flushed through its own manager. - Tags are case-insensitive. They are trimmed and lowercased before use, so
Productsandproductsare one tag.
withTags() adds tags to every write through that manager, and flushTags() on it flushes those tags together with any you pass:
$catalog = $this->cache->withNamespace('catalog')->withTags('products');
$catalog->put('list:page-1', $page); // tagged 'products'
$catalog->flushTags(); // flushes 'products' in 'catalog'
On Redis, an entry changed by another writer while a flush is running is left alone, and an entry this worker cannot decode is neither deleted nor dropped from the tag. Each tag set expires with its longest-lived member, so tags nobody flushes do not grow forever.
Tenants
Every key carries a tenant segment. With semitexa/tenancy installed, it comes from the current tenant context, so two tenants writing featured-products get two entries. Without a tenant, or for tenant default, every request shares tenant:default.
For data that belongs to no tenant, use the global scope:
use Semitexa\Cache\Domain\Enum\CacheScope;
$global = $this->cache->scope(CacheScope::Global);
$global->put('exchange-rates', $rates, ttlSeconds: 3600);
Global and tenant entries do not see each other, even under the same key: the global scope uses the segment scope:global, tenants tenant:<id>. Flushes stay inside the tenant (or the global scope) they were called in. A project can replace how the tenant segment is found by binding its own CacheNamespaceResolverInterface (#[SatisfiesServiceContract]).
Across workers
array |
redis |
|
|---|---|---|
A put() in worker 1 is visible in worker 2 |
no | yes |
| Survives a worker restart or recycle | no | yes, until its TTL |
forget() / flushes reach other workers |
no | yes |
| Redis unreachable | — | get() is a miss, untagged put() keeps nothing; forget() and flushes throw |
An invalidation that cannot reach Redis throws on purpose: one that silently did nothing would serve stale data once Redis is back.
Commands
bin/semitexa cache:forget <key> [<namespace>]
bin/semitexa cache:tags:flush <tag>...
bin/semitexa cache:flush [--namespace=<name>] [--scope=tenant|global]
They act on the root namespace of the current tenant unless told otherwise (bin/semitexa tenant:run <tenant> cache:flush for another tenant), through the same manager and resolver as the workers; cache:tags:flush has no namespace option. They run in their own CLI process, so with CACHE_DRIVER=array they only see that process's empty cache and change nothing in the running workers. cache:clear is a different command: it clears compiled templates under var/cache and cycles the workers; with --no-reload it clears the disk only, and running workers keep the templates they already compiled. It never touches Redis entries, though cycling the workers does empty an array cache.
Watch out for
arrayin production. Each worker has its own copy, a write in one is invisible to the others, andforget()only clears the worker that ran it.- Expired entries in
arraystay in memory until something reads them or a flush clears them; the worker's memory is the only limit. nullandget().remember()keeps anullits resolver returned, butget($key)cannot tell a storednullfrom a miss: pass a default you can recognise (get($key, $missing)) when the difference matters.- Tagged writes during an outage. If the tag index cannot be written, the entry is removed again (an entry no tag knows would survive every flush of its tags); the call itself does not fail. The outage is logged once, by the Redis circuit breaker, not once per write. Any other exception from a tag index is a bug and is thrown.
CACHE_DEFAULT_TTL=0means forever. It is refused (every cache call fails, andsystem:doctorreportscache.default-ttl) unlessCACHE_ALLOW_FOREVER=1.- Booleans. Write
CACHE_ALLOW_FOREVERandCACHE_TAGS_ENABLEDas1and0: every reader takes those, while not every one takestrue,yesoron.