Caching
The bundle uses Symfony cache pools for metadata, trust-store, and protocol state. What gets cached, where, and what to flush when.
The bundle uses Symfony cache pools (via PSR-16 wrapping) for two purposes:
- Metadata caching — node attribute reads, browse results.
- Protocol state — per-server chunk sizes, certificate validation outcomes, trust-store fingerprints.
Both are optional. With caching off, the bundle issues extra network calls but works correctly.
Choice of cache pool
The bundle wraps a Symfony PSR-6 pool as PSR-16 via the
php_opcua.psr16_cache service. The pool name comes from
session_manager.cache_pool (default cache.app).
Recommended pools:
| Pool | When |
|---|---|
cache.redis |
Multi-process apps (FPM, Messenger). The canonical choice. |
cache.adapter.doctrine_dbal |
When Redis isn't an option |
cache.app |
Default — file adapter in dev, the framework default in prod |
cache.array |
Tests — in-memory |
Configure a dedicated pool:
framework:
cache:
pools:
cache.opcua:
adapter: cache.adapter.redis
provider: 'redis://%env(REDIS_URL)%'
default_lifetime: 3600
Bundle config:
php_opcua_symfony_opcua:
session_manager:
cache_pool: cache.opcua
What gets cached
Node metadata
Reads of non-Value attributes (DisplayName, Description,
DataType, NodeClass, etc.) are cached when
read_metadata_cache: true on the connection:
connections:
default:
read_metadata_cache: true
Cache key: opcua.meta.{connection}.{nodeId}.{attribute}.
Default TTL: 1 hour.
The Value attribute is never cached — values must be live.
Browse results
browseRecursive() doesn't auto-cache. Do it explicitly:
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
public function tree(string $root): array
{
return $this->cache->get(
'opcua.browse.' . hash('xxh3', $root),
function (ItemInterface $item) use ($root) {
$item->expiresAfter(3600);
return $this->client->browseRecursive($root, maxDepth: 10);
},
);
}
Browse trees are stable for hours/days — cache aggressively.
Certificate validation
Cert chains are validated once per (cert fingerprint, trust store state) and cached:
| Cache key | TTL |
|---|---|
opcua.cert.valid.{fingerprint}.{store-hash} |
1 day |
opcua.cert.invalid.{fingerprint} |
1 hour |
Trust-store hashes
The list of pinned server certs is hashed and cached so the trust check is O(1) during connection open:
| Cache key | TTL |
|---|---|
opcua.trust.store-hash.{path} |
5 min |
Invalidated automatically by trust-store-modify operations.
Per-server protocol features
After the first connection:
| Cache key | TTL |
|---|---|
opcua.server.{endpoint}.chunk |
1 day |
opcua.server.{endpoint}.caps |
1 day |
opcua.server.{endpoint}.product |
1 day |
Rarely changes; second connection per day is faster than the first.
Flushing
php bin/console cache:pool:clear cache.opcua
For all pools:
php bin/console cache:pool:clear --all
For dev iteration (Symfony container cache):
php bin/console cache:clear
When NOT to cache
- Live values. Already covered. Never cache
read()Value results. - Per-request browse results. If the operator is browsing interactively, fresh browse is what they want.
- Cert validation during trust-store setup. Disable metadata caching while configuring — a stale validation result blocks you for the TTL.
v4.3 cache codec hardening
OPC UA client v4.3.0 removed unserialize() from every cache
code path. JSON gated by a type allowlist (WireCacheCodec)
is the new default.
Pre-v4.3 cache entries are silently discarded on first access. After upgrading, flush persistent pools to avoid a brief cold-cache window:
php bin/console cache:pool:clear cache.opcua
See Upgrading.
Octane / FrankenPHP — long-process cache
In FrankenPHP / RoadRunner workers, an array-driver cache persists across requests — desirable for metadata caches. The bundle's caches are happy with this.
connectTo()-driven ad-hoc configs each get their own cache
entry — beware of unbounded growth in fleet apps. Periodic
worker recycle (via --time-limit) keeps memory bounded.
Multi-instance coordination
| Topology | Pool | Coherent? |
|---|---|---|
| Single host | file |
Yes |
| Single host | redis (local) |
Yes |
| Multi-host | file |
No — each host has its own cache |
| Multi-host | redis (shared) |
Yes |
| Multi-host | doctrine |
Yes |
For multi-host deployments, use shared Redis.
Cache size
Per-cached-server state is ~1 KB. 1000 cached metadata entries
= 1 MB. 100 000 = 100 MB. Set
MAXMEMORY_POLICY=allkeys-lru on Redis to bound it.
A custom cache pool example
For OPC UA only, with a longer default TTL:
framework:
cache:
pools:
cache.opcua_metadata:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
default_lifetime: 86400
tags: true
The tags: true option enables tag-based invalidation if your
custom logic uses tags for cache groups.
Where to read next
- Debugging — when cache makes diagnosis harder.
- Profiler and data collectors — see cache hits/misses in the WebProfiler.
- Trust store — the cache layer over the trust store.