moselwal / keyvalue-store
KeyValue Store integration for TYPO3 (Redis/Valkey) with PHPRedis >= 6.3, Sentinel and TLS/mTLS.
Package info
github.com/ohartwig/keyvalue-store
Type:typo3-cms-extension
pkg:composer/moselwal/keyvalue-store
Requires
- php: ^8.3
- ext-redis: >=6.3
- typo3/cms-core: ^14.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.56.0
- koh/dev-typo3: ^1.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Redis/Valkey integration for TYPO3 14: cache backend, session backend,
distributed locking. Production-ready Sentinel discovery, TLS, and mTLS.
Built-in override pack against TYPO3 Core's legacy Redis anti-patterns
(KEYS, blocking DEL).
Components
| Component | Class | Drop-in for |
|---|---|---|
| Cache backend | KeyValueBackend |
TYPO3\CMS\Core\Cache\Backend\RedisBackend |
| Session backend | KeyValueSessionBackend |
TYPO3\CMS\Core\Session\Backend\RedisSessionBackend |
| Locking strategy | KeyValueLockingStrategy |
LockingStrategyInterface (registered automatically) |
Installation
composer require moselwal/keyvalue-store ```text ### Requirements - PHP 8.3+ - TYPO3 14.x - `ext-redis` >= 6.3 (phpredis with the v6 constructor-config API, Sentinel resolver, TLS context, and decorrelated-jitter backoff) - Redis 4.0+ or Valkey (UNLINK is required for the v4.2.0 override pack) ## Configuration The cleanest path is via [`moselwal/typo3-config`](../typo3-config) — its `autoconfigureCaching()`, session-binding, and locking helpers wire all three components consistently. Manual configuration is supported and documented below. ### Cache backend ```php $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages'] = [ 'backend' => \Moselwal\KeyValueStore\Cache\Backend\KeyValueBackend::class, 'options' => [ 'hostname' => 'valkey.internal', 'port' => 6379, 'database' => 3, 'password' => 'secret', 'persistentConnection' => true, 'defaultLifetime' => 2592000, 'compression' => true, // TLS / mTLS (optional) 'tls' => true, 'ca_file' => '/run/tls/ca.crt', 'cert_file' => '/run/tls/httpd.crt', 'key_file' => '/run/tls/httpd.key', 'peer_name' => 'valkey.internal', 'verify_peer' => true, 'verify_peer_name' => true, // Sentinel (optional) 'sentinel' => true, 'sentinel_host' => 'sentinel.internal', 'sentinel_port' => 26379, 'sentinel_service' => 'mymaster', // phpredis 6.x backoff (optional) 'backoff' => [ 'algorithm' => \Redis::BACKOFF_ALGORITHM_DECORRELATED_JITTER, 'base' => 500, 'cap' => 750, ], ], ];
Session backend
$GLOBALS['TYPO3_CONF_VARS']['SYS']['session']['BE'] = [ 'backend' => \Moselwal\KeyValueStore\Session\Backend\KeyValueSessionBackend::class, 'options' => [ 'hostname' => 'valkey.internal', 'database' => 1, 'password' => 'secret', 'persistentConnection' => true, 'persistentId' => 'typo3-session-be', 'prefix' => 'typo3:sess:be:', 'sessionLifetime' => 3600, // TLS / Sentinel options as above ], ]; ```text ### Locking strategy The locking strategy registers via TYPO3's `LockFactory` configuration: ```php $GLOBALS['TYPO3_CONF_VARS']['SYS']['locking']['strategies'][ \Moselwal\KeyValueStore\Locking\KeyValueLockingStrategy::class ] = [ 'hostname' => 'valkey.internal', 'database' => 0, 'password' => 'secret', 'persistentConnection' => true, 'ttl' => 10, // TLS / Sentinel options as above ];
What v4.x does differently from TYPO3 Core's RedisBackend
The KeyValueBackend extends RedisBackend and overrides four
operations to drop server-side anti-patterns. The other operations
(get, set, has, remove, findIdentifiersByTag) are inherited
verbatim — Core already pipelines tag tracking there, so there is
nothing to add.
| Operation | TYPO3 Core | KeyValueBackend (v4.3.0) |
|---|---|---|
set() |
SETEX + SMEMBERS + (optional MULTI/PIPELINE) tag diff | Single Lua EVAL (atomic, 1 roundtrip) |
flush() |
KEYS prefix* + DEL (event-loop block for all clients) |
SCAN + UNLINK batches (server stays responsive) |
flushByTag() / flushByTags() |
N× sequential flushByTag() fan-out |
One sUnion + one pipelined UNLINK |
collectGarbage() |
KEYS identTags:* |
SCAN-loop |
| Connection | pconnect() only, no Sentinel/TLS |
KeyValueConnectionFactory (Sentinel resolver, mTLS, backoff) |
| Serializer | hard-coded PHP-native | configurable: php / igbinary / none / auto |
Additionally:
lazy=trueis the default — the TCP/TLS handshake is deferred to the first command. Bootstrap of 11 cache backends drops from ~25 ms to ~0.07 ms on a real mTLS Valkey setup.OPT_SCAN = SCAN_RETRYis set on every connection so phpredis retries empty SCAN pages internally instead of returningfalseto the caller (a well-known phpredis 6.x footgun).
Connection reuse in a FrankenPHP worker (KEYVALUE_CONNECTION_REUSE)
Off by default. With KEYVALUE_CONNECTION_REUSE=1 in a FrankenPHP worker,
the factory keeps one \Redis object per complete option set for the life
of the worker, and later requests get it back instead of a new one. A request
then no longer sends AUTH, ECHO and SELECT again for every cache,
session and lock backend it touches (on the TYPO3 page cache hit of one
installation: 21 Valkey commands per request down to 9).
- One object per option set: everything the backend passed to
create(), including TLS, credentials, database, serializer, compression and key prefix. Two option sets that differ in anything never share an object. - Dropped and rebuilt when it is not in ATOMIC mode (a request ended inside
MULTIor a pipeline), when it is on another database than its options say, when the server reported an error on it, or when a backend saw a connection error on it. Client options a backend set aftercreate()(serializer, prefix, SCAN mode, ...) are put back before it is handed out. - A connection the server closed meanwhile reconnects on its next command
(phpredis applies
AUTHandSELECTagain from its configuration). - Never kept: Sentinel option sets (the master is resolved on every
create()), option sets withoutlazy, and anything outside a FrankenPHP worker ($_SERVER['FRANKENPHP_WORKER']).
The switch is read from $_SERVER, $_ENV or the process environment, so it
can be set in the worker's env lines or in the container.
Bench (real, container-side against Valkey/mTLS, phpredis 6.3.0)
| Operation | Core / v4.0.x | v4.3.0 | Δ |
|---|---|---|---|
| Bootstrap 11 caches | 25.1 ms | 0.07 ms | 381× |
getAll() 500 sessions |
37.2 ms | 1.5 ms | 24.6× |
renew() (session fixation) |
360 µs | 161 µs | 2.2× |
| Retry-Backoff (2 failures) | 162 ms | 31 ms | 5.1× |
set() 1 tag |
353 µs | 264 µs | 1.3× |
set() 5 tags |
421 µs | 266 µs | 1.6× |
set() 10 tags |
421 µs | 286 µs | 1.5× |
set() 20 tags |
582 µs | 299 µs | 1.9× |
flushByTags(10 tags) |
4.1 ms | 1.2 ms | 3.3× |
flush(10 k keys) |
7.4 ms | 9.5 ms | −30 % wallclock, no event-loop block |
collectGarbage(5 k keys) |
1.4 ms | 2.8 ms | −50 % wallclock, no event-loop block |
The flush() and collectGarbage() overrides are intentionally
slower wallclock-wise for the caller. The trade-off is server-side
fairness: while KEYS runs, every other client in the Valkey
instance is blocked. With SCAN, the server can interleave other
clients between batches — for a multi-site, multi-pod setup that is
the more important property. Neither path is a hot-path operation
(flush() is BE/CLI-triggered; collectGarbage() runs in the
scheduler tick).
Session backend internals
KeyValueSessionBackend is a from-scratch implementation of TYPO3's
SessionBackendInterface. Notable choices:
- JSON serialisation for session payloads (not PHP-native) so
records remain debuggable with
valkey-cli - WATCH / MULTI / EXEC on
update()with bounded retries — optimistic concurrency, two-tab login is safe - Lua EVAL for
renew()— atomic GET/SETEX/DEL so concurrent updates cannot be lost during the rename getAll()via SCAN + MGET instead of SCAN + N× GET — 24.6× faster on 500 sessions- The session ID is never persisted. The key and the record's
ses_idboth holdHMAC-SHA256(sessionId, hashSecret)(the secret defaults toSYS/encryptionKey), as core's own backends store the hash; the backend implementsHashableSessionBackendInterface, so the Backend users module marks the viewer's own session, andremove()accepts the plain or the hashed ID. Exception messages never name the session. Records written by earlier versions, which carried the plain ID inses_id, stay readable:getAll()reports the hash for them andget()rewrites them on the first read (compare-and-set, TTL kept).
Locking strategy internals
KeyValueLockingStrategy uses the Lua-EVAL + BLPOP pattern recommended
by the Redis docs:
tryLock()is a single atomicSET key value NX EX ttlwait()blocks via server-sideBLPOPon a signal list — no client-side pollingunlockAndSignal()runs a Lua script that atomically verifies ownership (GET), releases (DEL), and wakes one waiter (RPUSH + EXPIRE)
Architecture
Classes/
├── Cache/Backend/
│ └── KeyValueBackend.php (extends TYPO3 Core RedisBackend)
├── Session/Backend/
│ └── KeyValueSessionBackend.php (implements SessionBackendInterface)
├── Locking/
│ └── KeyValueLockingStrategy.php (implements LockingStrategyInterface)
└── Connection/
├── KeyValueConnectionFactory.php
├── SentinelResolver.php
├── TlsContextBuilder.php
└── ValueObject/
├── ConnectionParams.php
└── Endpoint.php
All three TYPO3-facing components route their phpredis instantiation
through KeyValueConnectionFactory so Sentinel, TLS, mTLS, and the
phpredis 6.x backoff config are configured in exactly one place.
Development
Tooling lives in koh/dev. Common commands:
composer install vendor/bin/phpunit -c Tests/phpunit.xml --testsuite=Unit vendor/bin/phpunit -c Tests/phpunit.xml --testsuite=Functional # needs Redis vendor/bin/phpstan analyse Classes --level=8 vendor/bin/php-cs-fixer fix Classes Tests --config=vendor/moselwal/dev/.php-cs-fixer.dist.php ```text Tests gated with `#[RequiresPhpExtension('redis')]` skip locally when ext-redis is missing and run in CI / dev containers where it is installed. ## Dependencies | Package | Type | Purpose | |---|---|---| | `php` ^8.3 | Required | Language baseline | | `typo3/cms-core` ^14.0 | Required | TYPO3 core | | `ext-redis` >= 6.3 | Required | phpredis with v6 constructor API | | `moselwal/dev` ^5.2 | Dev | Shared QA tooling (phpstan, cs-fixer, etc.) | ## Related - [`moselwal/typo3-config`](../typo3-config) — Fluent TYPO3 config API with `autoconfigureCaching()` that wires this package - [`moselwal/cluster-file-backend`](../cluster-file-backend) — Pod-local cache backend that uses this package's `KeyValueBackend` as its metadata storage layer ## Serializer `KeyValueBackend` defaults to PHP-native serialization (`SERIALIZER_PHP`). Operators can opt into other phpredis serializers via the `serializer` option: ```php 'options' => [ 'serializer' => 'php' | 'igbinary' | 'none' | 'auto', // … ],
| Value | Behaviour |
|---|---|
'php' (default) |
PHP-native, BC-safe, identical to v4.2.0 |
'igbinary' |
igbinary when ext-igbinary is loaded; falls back to PHP-native with a notice otherwise |
'none' |
No phpredis-layer serialization; the caller serializes |
'auto' |
igbinary if loaded, php otherwise (not the default, see below) |
⚠️ Switching the serializer requires a full cache flush of all affected cache databases. Existing payloads stay in the previous format and will fail to deserialize on read. Recommended deploy sequence:
# 1. Flush each affected Valkey DB valkey-cli -n 3 FLUSHDB # pages valkey-cli -n 4 FLUSHDB # hash # … repeat for each cache DB # 2. Restart workers so connections re-initialise with the new option # 3. Deploy the new typo3-config with the serializer change ```text **When `auto` is the wrong default**: an image update that ships `ext-igbinary` would silently switch the on-disk format for any cache that uses `auto` — and on the next read of an old PHP-serialised payload, the cache would throw. Pinning the value explicitly (`'php'` or `'igbinary'`) makes the contract observable. **When `igbinary` is worth it**: only for caches storing deeply nested arrays/objects (e.g. extbase ClassSchema, fluid template reflection). For string-content caches (rendered pages, large text blobs) or flat key/value caches (`hash`, `imagesizes`) the igbinary encoder overhead dominates the marginal payload-size win — keep the default. See [CHANGELOG.md](CHANGELOG.md) for measured numbers. The session backend (`KeyValueSessionBackend`) uses JSON internally for debuggability via `valkey-cli` — this option has no effect there. ## Changelog See [CHANGELOG.md](CHANGELOG.md). Notable releases: - **v4.3.0** — Lua-EVAL `set()` (1.3×–1.9× faster); `serializer` opt-in; KeyValueLockingStrategy audit (configurable `maxAcquireAttempts`, lazy connect, TTL-cache in `wait()`, log-level differentiation) - **v4.2.0** — TYPO3 Core override pack (`flush`, `flushByTag`, `collectGarbage`); critical fix for `getAll()` SCAN cursor - **v4.1.0** — Lazy-connect, MGET-based `getAll()`, atomic `renew()`, decorrelated-jitter backoff - **v4.0.0** — PHP 8.5 baseline, TYPO3 14-only, phpredis 6.3 required ## License GPL-2.0-or-later — see [LICENSE](LICENSE) for details.