Stores
A store is not a Redis structure but a way of dividing one database between the parts of an application: a key prefix plus the handful of commands most code actually uses. Below: why it is needed, and every method in detail — what it takes, what it returns, what it throws.
What a store is and why
The problem. Redis has no namespaces. Every key lives in one flat database, and the key
42 written by the session module is indistinguishable from the key 42 written by the job
queue: whoever came second overwrote the first. The only way to keep them apart is to agree
on names. That agreement lives in comments and in the team’s memory, the string 'session:'
gets duplicated all over the code, and one day somebody writes 'sessions:'.
The solution. The agreement becomes a class. A store knows its prefix and applies it
itself — the code keeps the short key name, and the full one is assembled in a single place.
A boundary comes with it: keys() and flush() operate within the store, not the whole
database.
<?php
namespace Main\Stores;
use Flytachi\Winter\Redis\Store\RedisStore;
use Main\Configurations\MainRedisConfig;
class SessionStore extends RedisStore
{
protected string $redisConfigClassName = MainRedisConfig::class; // required
protected string $prefix = 'session:'; // optional
}The class sets two things: where to go (the configuration) and under what name to live there (the prefix). After that it is an ordinary dependency:
use Flytachi\Winter\DI\Attribute\Autowired;
class AuthService
{
#[Autowired]
private SessionStore $sessions;
public function remember(int $userId, string $token): void
{
$this->sessions->set((string) $userId, $token, ttl: 3600);
}
}A store holds no connection: every call asks the pool for the client of the current unit of work. That is why a store may safely live in a field of a singleton, even though a connection may not.
What the prefix buys
$sessions->set('42', 'token'); // on the server: session:42
$queue->set('42', 'job'); // on the server: queue:42
$sessions->get('42'); // 'token'
$queue->get('42'); // 'job'Two stores on one database do not collide, and flush() and keys()
operate within their own store. The prefix is simply the beginning of a key’s name, not a
separate entity on the server: it has to be unique within the database, and the trailing
colon is a habit, not a requirement.
A store without a prefix is allowed — when the application is the database’s sole owner, for
instance. But then flush() will refuse to work: without a prefix the pattern
matches everything, including keys you do not know about.
Reference
RedisStore
RedisStore is the abstract class an application’s stores extend. It is not instantiated on
its own: the point of a store is the two properties the subclass sets.
$redisConfigClassName — the configuration class, that is, the connection point. Required.
$prefix — the string prepended to every key. Empty by default.
The class’s sixteen methods fall into four groups: working with values, counters, surveying the contents, and dropping a level — structure handles and the raw client.
Values
get()
Reads the value at a key.
Returns whatever the key holds, and null when there is no key. The driver answers false
in that spot, which is indistinguishable from a stored empty value; null removes the
ambiguity at least for absence.
Syntax
public function get(string $key): mixedParameters
$key — the key’s name without the prefix; the store assembles the full one.
Returns
The value, or null if the key does not exist.
Errors
RedisCommandException — if the key is occupied by a structure of another type: a list, a
hash, a stream. That is a bug in the code, not missing data, so it does not pretend to be
null.
Example
$store->get('token'); // 'abc123'
$store->get('nothing'); // nullResult on the wrong type
RedisCommandException: WRONGTYPE Operation against a key holding the wrong kind of valueWhat comes back if you store `false`
It depends on the serializer, and both cases are worth knowing.
Without a serializer (the default) false goes to the server as an empty string, and
get() returns '' — neither false nor null.
With a serializer configured the value survives the round trip and arrives from the driver as
false, and the store turns false into null — at which point it is no longer
distinguishable from a missing key.
If you need to store a boolean as such, encode it ('0' / '1') or check for presence
separately with has().
set()
Stores a value, optionally with a lifetime.
Writing over an existing key clears its lifetime unless a new one is given — exactly as a
plain SET does in Redis.
Syntax
public function set(string $key, mixed $value, ?int $ttl = null): boolParameters
$key — the key’s name without the prefix.
$value — the value. With the default serializer this must be a string or a number: the
server stores the bytes verbatim, and an array turns into the string "Array". To store
arrays and objects you need a serializer — see
Configuration.
$ttl — the lifetime in seconds, counted from now. null by default — the key lives
until it is deleted.
Returns
true if the write went through.
Errors
LogicException — if $ttl is zero or negative. Redis will not accept such a lifetime, and a
quiet false in reply would read as “did not save, for some unknown reason”:
LogicException: A ttl must be a positive number of seconds; got 0.
To remove a key, call delete().Example
$store->set('token', $jwt); // forever
$store->set('token', $jwt, ttl: 3600); // for an hour`ttl` is a duration, not a moment in time
ttl: 60 means “sixty seconds from now”. ttl: time() + 60 asks for roughly fifty-six
years, and nothing reports it — the key simply never expires. It will not show up
immediately, and when it does it will look like a memory leak on the server.
There is deliberately no absolute moment in the store’s API — one argument carrying two similar meanings is exactly what breeds that confusion. When a moment is what you need, the driver has it:
$store->set('session', $data);
$store->raw()->expireAt($store->key('session'), $expiresAt);has()
Checks whether a key exists.
Unlike get() !== null, the value is not sent over the wire — on large values that is
noticeable. And it is the only way to tell a stored empty value from a missing key.
Syntax
public function has(string $key): boolParameters
$key — the key’s name without the prefix.
Returns
true if the key exists.
Example
$store->has('token'); // true
$store->has('nothing'); // falsedelete()
Deletes one or several keys.
Deleting a key that does not exist is not an error. The returned number is useful when you need to know whether the work was actually done: “the session really did exist”.
Syntax
public function delete(string ...$keys): intParameters
...$keys — key names without the prefix, as many as you like. You may pass none at all —
then no request goes to the server.
Returns
The number of keys that existed and were deleted.
Example
$store->delete('token'); // 1
$store->delete('token'); // 0 — it is already gone
$store->delete('a', 'b', 'c'); // 2, if 'c' did not exist
$store->delete(); // 0, without touching the serverttl()
Reports how long a key has left to live.
Syntax
public function ttl(string $key): ?intParameters
$key — the key’s name without the prefix.
Returns
The remaining seconds, or null — both when no lifetime is set and when there is no key.
If telling those two apart matters, ask has().
Example
$store->set('token', $jwt, ttl: 3600);
$store->ttl('token'); // 3600
$store->ttl('forever'); // null — the key exists, no lifetime
$store->ttl('nothing'); // null — no keyCounters
increment()
Atomically increases a numeric value.
This is one command on the server, not “read, add, write”: two simultaneous requests
cannot both read 10 and both write 11. That is exactly why counters are kept in Redis
rather than in an application variable.
Syntax
public function increment(string $key, int $by = 1): intParameters
$key — the key’s name without the prefix. If the key did not exist it is created with the
value 0 and increased straight away.
$by — how much to add. 1 by default.
Returns
The new value.
Errors
RedisCommandException — if the key holds something that is not a number:
$store->set('name', 'Alice');
$store->increment('name');RedisCommandException: ERR value is not an integer or out of rangeExample
$store->increment('hits'); // 1
$store->increment('hits', 10); // 11A counter with a window
// No more than a hundred requests a minute from one user
$key = "rate:{$userId}";
$hits = $store->increment($key);
if ($hits === 1) {
$store->raw()->expire($store->key($key), 60); // the window starts with the counter
}
if ($hits > 100) {
throw new TooManyRequests();
}decrement()
Atomically decreases a numeric value.
The exact opposite of increment() and identical to it in every other respect:
the same atomicity, the same creation of a missing key from zero, the same exception on a
non-numeric value.
Syntax
public function decrement(string $key, int $by = 1): intParameters
$key — the key’s name without the prefix.
$by — how much to subtract. 1 by default.
Returns
The new value. It may go below zero — there is no lower bound.
Errors
RedisCommandException — if the key holds something that is not a number.
Example
$store->increment('hits', 10); // 10
$store->decrement('hits'); // 9
$store->decrement('hits', 3); // 6Surveying the contents
keys()
Lists the store’s keys.
An inventory tool: see what is in there, build a list to delete, show the contents in an admin panel. It is not for fetching data — a value is fetched by its known name.
Syntax
public function keys(string $pattern = '*'): arrayParameters
$pattern — a glob pattern that applies inside the store: the prefix is added
automatically, so 'user:*' finds session:user:1 and not everything in the database. '*'
by default — every key of the store.
Returns
An array of names without the prefix — exactly the form get() and
delete() accept. An empty array for an empty store.
Example
$store->keys(); // ['42', 'abc']
$store->keys('user:*'); // only what matched, still inside the storeThis is `SCAN`, not `KEYS` — and the difference is not cosmetic
KEYS walks the whole keyspace in one command, and Redis is single-threaded: on a large
database it stops every client for the duration of that walk. SCAN goes in batches and
lets the server breathe in between.
The price is that the result is assembled in pieces: a key added during the walk may or may not make it in; a deleted one may still turn up in the list. For an inventory that is fine, for an exact count it is not.
The whole result is materialised in memory, so on a store with millions of keys it is worth giving a narrow pattern.
flush()
Deletes every key of the store.
Inside it is the same batched SCAN with deletes, so on a large store this is a walk rather
than an instant operation. In exchange the server is not blocked.
Syntax
public function flush(): intParameters
None.
Returns
The number of keys deleted.
Errors
LogicException — if the store has no prefix.
Example
$sessions->flush(); // 128 — neighbouring stores untouchedA store without a prefix cannot be flushed
LogicException: Main\Stores\LegacyStore::flush() needs a $prefix —
without one it would delete the whole database.This is a refusal, not an over-precaution: without a prefix the pattern matches the whole
database, other people’s keys included. Wiping the database is a deliberate act, and it is
done explicitly: raw()->flushDB().
Structures
Three methods return a handle — an object through which one data structure is worked with. They share one thing, and it is the important one: none of them sends anything to the server. A handle is a view of a key, not a request, so getting one for a key that does not exist is fine and costs nothing.
The prefix is applied at the moment the handle is taken — after that there is nowhere left to
forget it. That is the difference from raw(), where it has to be remembered on
every command.
hash()
Returns a handle on the hash stored under this key.
A hash is a dictionary inside a single key: fields and their values. In detail — Hashes.
Syntax
public function hash(string $key): RedisHashParameters
$key — the key’s name without the prefix.
Returns
A RedisHash bound to the full key name.
Example
$store->hash('cart:42')->set('qty', '2');list()
Returns a handle on the list stored under this key.
A list is an ordered sequence with fast operations at both ends; queues are built on it. In detail — Lists.
Syntax
public function list(string $key): RedisListParameters
$key — the key’s name without the prefix.
Returns
A RedisList bound to the full key name.
Example
$store->list('jobs')->push($payload);stream()
Returns a handle on the stream stored under this key.
A stream is a log of records that several consumers read independently of one another. In detail — Streams.
Syntax
public function stream(string $key): RedisStreamParameters
$key — the key’s name without the prefix.
Returns
A RedisStream bound to the full key name.
Example
$store->stream('events')->add(['type' => 'signup']);Direct access
raw()
Returns the client of the current unit of work.
Redis has hundreds of commands, and wrapping them all is an endless job. Everything the store
and the handles do not wrap is reached from here: sorted sets, pub/sub, SETNX, Lua scripts.
Syntax
public function raw(): \RedisParameters
None.
Returns
A \Redis object — the ext-redis client, already connected and on the right database.
Example
$store->raw()->zAdd($store->key('leaders'), 100, 'user:1');
$store->raw()->publish('events', $payload);The prefix is not applied here — and it fails silently
$store->raw()->zAdd('leaders', ...) throws nothing and returns no error. The key is simply
written outside the store, and everything looks like it works.
The consequences turn up later and elsewhere: keys() and get() will not
see that key, flush() will not delete it, and two stores that both forgot the
prefix quietly share one name — exactly the collision the prefix exists to prevent. A
forgotten key() does not break the call, it removes the store’s only guarantee.
The rule is simple: every key that goes into raw() is wrapped in key() — or,
if a structure owns one key for the whole job, take a hash() or
list() handle, where the prefix is already applied.
The client itself is valid for this request only: under Swoole it goes back to the pool when the coroutine ends. Stored in a field of a long-lived object, it hands one socket to every later request — precisely what the pool exists to prevent.
key()
Assembles the full key name — the one the server will see.
Needed everywhere a key goes into raw() or transaction(): the
prefix is not applied there.
Syntax
public function key(string $name): stringParameters
$name — the key’s name without the prefix.
Returns
The string “prefix + name”.
Example
$store->key('42'); // 'session:42'
$store->key(''); // 'session:' — the prefix itselftransaction()
Runs a callback on one and the same connection from beginning to end.
This is the one place where the automatic return of a connection to the pool is not enough.
MULTI and pipeline() hold state on the connection: the server remembers the sequence
that was started and waits for EXEC on the same socket. If the middle of the sequence goes
out on a different connection, the commands split across two sockets and the outcome is
anyone’s guess.
Syntax
public function transaction(callable $callback): mixedParameters
$callback — a function receiving the raw \Redis client. Everything it calls goes over one
connection.
Returns
Whatever the callback returned, unchanged.
Example
$result = $store->transaction(function (\Redis $redis) use ($store) {
$redis->multi();
$redis->incr($store->key('a'));
$redis->incr($store->key('b'));
return $redis->exec();
}); // [1, 1]Keys are not prefixed automatically here — the callback gets the raw client, so
key() is as mandatory as it is in raw().
A Redis transaction is not a database transaction
MULTI/EXEC guarantee that the commands run back to back with no other client’s commands
in between. There is no rollback: if one command fails, the rest still apply. Conditions have
to be checked before the sequence, not after.
configClass()
Returns the configuration class this store is bound to.
For diagnostics, and for the places that need the connection point given a store: for
instance RedisPool::stats() reports utilisation keyed by
configuration name.
Syntax
public function configClass(): stringParameters
None.
Returns
The fully qualified name of the configuration class.
What a store expects of values
With the default serializer (SERIALIZER_NONE) a value must be a string or a number: the
server stores the bytes it is sent verbatim, and PHP coerces everything else to a string.
$store->set('user', ['id' => 1]); // the string "Array" in the database, only a PHP warning
$store->get('user'); // 'Array'
$store->set('n', 42);
$store->get('n'); // '42' — a string, not an intTo store arrays and objects and get them back as the same types, set a serializer in the configuration. It applies to the connection, so it covers hash field values and list elements too.
Mapping to Redis commands
| Method | Command |
|---|---|
get() / set() |
GET / SET, with a lifetime SETEX |
has() / delete() |
EXISTS / DEL |
increment() / decrement() |
INCRBY / DECRBY |
ttl() |
TTL |
keys() |
SCAN, not KEYS |
flush() |
SCAN + DEL |
hash() / list() / stream() |
— (send nothing) |
key() / raw() / configClass() |
— |
transaction() |
whatever the callback calls |
Next
- Hashes — fields, their lifetimes and the server version they need
- Lists — queues and blocking reads
- Streams — an event log and consumer groups
- Configuration — the connection point, database, serializer
- Connection pool — where the client comes from and when it goes back