Stores
A store is not a Redis structure but a way to share one database between parts of an application: a key prefix plus the commands most code actually uses. Below: why it exists, and every method with its arguments and examples.
What a store is
Redis has no namespaces: every key lives in one flat database, and a 42 belonging to
sessions is indistinguishable from a 42 belonging to a queue. The only way to keep
them apart is to agree on names. A store turns that agreement into code.
<?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 states two things: where to go (the config) and under what name to live there (the prefix). From then on 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. Which is why a store may safely live in a property of a singleton, although 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 disturb each other, and flush() and keys() work
within the store rather than the database. A prefix is simply the beginning of a key
name, not a separate thing on the server: it has to be unique within the database, and
the trailing colon is a habit, not a requirement.
When a prefix is unnecessary
A store without one is allowed — when the application is the sole owner of the database,
for instance. But then flush() refuses to work: without a prefix the pattern matches
everything, including keys you do not know about.
Method reference
Values
get()
public function get(string $key): mixedReads the value of a key.
| Argument | Type | Default | What it does |
|---|---|---|---|
$key |
string |
— | the key name, without the prefix |
Returns the value, or null when there is no such key. The driver reports false
there, which is indistinguishable from a stored false; null removes the ambiguity.
Throws RedisCommandException when the key holds a structure of another type — a
list, a hash. That is a bug in the code rather than missing data, so it does not disguise
itself as null.
$store->get('token'); // 'abc123'
$store->get('nothing'); // nullIf you need to store false itself
A stored false comes back as null — get() cannot tell them apart. Store such values
encoded ('0'/'1'), or check existence separately with has().
set()
public function set(string $key, mixed $value, ?int $ttl = null): boolWrites a value, optionally with a lifetime.
| Argument | Type | Default | What it does |
|---|---|---|---|
$key |
string |
— | the key name without the prefix |
$value |
mixed |
— | the value. A string or a number; anything else needs a serializer |
$ttl |
?int |
null |
lifetime in seconds from now. null means none |
Returns true when the write went through. Throws LogicException when $ttl is
zero or less: Redis will not accept such a lifetime, and a silent false would read as
“it did not save, for some reason”.
$store->set('token', $jwt); // forever
$store->set('token', $jwt, ttl: 3600); // for an hour
$store->set('token', $jwt, ttl: 0); // LogicExceptionttl 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 will say so: the key simply never expires. It surfaces
late and looks like a memory leak on the server.
There is deliberately no absolute moment in the store API — one argument carrying two similar meanings is what breeds this confusion. When you do need a moment:
$store->set('session', $data);
$store->raw()->expireAt($store->key('session'), $expiresAt);Rewriting an existing key drops its lifetime when no new one is given — exactly as a
plain SET does in Redis.
has()
public function has(string $key): bool| Argument | Type | Default | What it does |
|---|---|---|---|
$key |
string |
— | the key name without the prefix |
Returns true when the key exists. Unlike get() !== null, the value never crosses
the network — noticeable for large values, and it is the only way to tell a stored
false from an absent key.
delete()
public function delete(string ...$keys): intDeletes one or more keys.
| Argument | Type | Default | What it does |
|---|---|---|---|
...$keys |
string |
— | key names without the prefix; none is allowed |
Returns how many keys existed and were deleted. With no arguments it is 0 and no
request is sent.
$store->delete('token'); // 1
$store->delete('token'); // 0 — already gone
$store->delete('a', 'b', 'c'); // 2, when 'c' was not thereDeleting a missing key is not an error. The returned number is useful when you need to know whether work was actually done — “the session really did exist”, say.
Counters
increment() and decrement()
public function increment(string $key, int $by = 1): int
public function decrement(string $key, int $by = 1): intChange a numeric value atomically.
| Argument | Type | Default | What it does |
|---|---|---|---|
$key |
string |
— | the key name without the prefix |
$by |
int |
1 |
how much to change it by |
Return the new value. A key that did not exist is created at 0 and then changed.
Throw RedisCommandException when the key holds something that is not a number.
$store->increment('hits'); // 1
$store->increment('hits', 10); // 11
$store->decrement('hits'); // 10This is one server-side command, not “read, add, write”: two concurrent requests cannot
both read 10 and both write 11. That is precisely why counters live in Redis and not
in an application variable.
// a request counter with a time window
$key = "rate:{$userId}";
$hits = $store->increment($key);
if ($hits === 1) {
$store->raw()->expire($store->key($key), 60); // a one-minute window
}
if ($hits > 100) {
throw new TooManyRequests();
}$store->set('name', 'Alice');
$store->increment('name'); // RedisCommandException: value is not an integerSuch a call used to return 0 — a refusal that looked like “the counter reset”. Now it
is an exception.
ttl()
public function ttl(string $key): ?int| Argument | Type | Default | What it does |
|---|---|---|---|
$key |
string |
— | the key 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 apart matters, ask has().
$store->set('token', $jwt, ttl: 3600);
$store->ttl('token'); // 3600
$store->ttl('forever'); // null — the key exists, the lifetime does not
$store->ttl('nothing'); // null — there is no keySurveying the contents
keys()
public function keys(string $pattern = '*'): arrayLists the store’s keys.
| Argument | Type | Default | What it does |
|---|---|---|---|
$pattern |
string |
'*' |
a glob applied inside the store |
Returns an array of names without the prefix — in the form get() and delete()
accept. For an empty store, an empty array.
$store->keys(); // ['42', 'abc']
$store->keys('user:*'); // only what matches, 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 stalls every client for the duration. SCAN goes in batches and lets the
server breathe in between.
The price is that the result is assembled over time: a key added during the walk may or may not appear, and one deleted meanwhile may still make the list. Fine for taking inventory, wrong for an exact count.
The whole result is materialised in memory, so on a store holding millions of keys give it a narrower pattern.
flush()
public function flush(): intDeletes every key of the store. No arguments.
Returns how many keys were removed. Throws LogicException when the store has no
prefix.
$sessions->flush(); // 128 — neighbouring stores untouchedInternally it is the same batched SCAN with deletes, so on a large store it is a walk
rather than an instant operation. In exchange, the server is never blocked.
A store without a prefix cannot be emptied
LogicException: Main\Stores\LegacyStore::flush() needs a $prefix —
without one it would delete the whole database.That is a refusal, not caution: without a prefix the pattern matches the whole database,
other people’s keys included. Emptying the database is a deliberate act:
raw()->flushDB().
Structures
hash(), list() and stream()
public function hash(string $key): RedisHash
public function list(string $key): RedisList
public function stream(string $key): RedisStreamReturn a handle bound to the key: on a hash, on a list or on a stream.
| Argument | Type | Default | What it does |
|---|---|---|---|
$key |
string |
— | the key name without the prefix |
Return a view object. Neither call sends anything to the server: it is a look at a key, not a request.
$store->hash('cart:42')->set('qty', '2');
$store->list('jobs')->push($payload);
$store->stream('events')->add(['type' => 'signup']);The prefix is applied when the handle is taken — there is nowhere left to forget it.
That is the difference from raw(), where remembering is on you.
Direct access
raw()
public function raw(): \RedisReturns the client of the current unit of work. No arguments.
Redis has hundreds of commands and wrapping them all is an endless job. Everything the
store and the handles do not cover comes from here: sorted sets, pub/sub, SETNX,
scripts.
$store->raw()->zAdd($store->key('leaders'), 100, 'user:1');
$store->raw()->publish('events', $payload);The prefix is not applied here — and that failure is silent
$store->raw()->zAdd('leaders', ...) throws nothing and reports no error. The key is
simply written outside the store, and everything looks like it worked.
The consequences arrive later and elsewhere: keys() and get() will not see that key,
flush() will not delete it, and two stores that both forget the prefix quietly share
one name — the very collision the prefix was introduced to prevent. A forgotten key()
does not break the call, it cancels the store’s only guarantee.
The rule is simple: every key that reaches raw() goes through key() — or, when a
structure has one key for the whole piece of work, take a hash()/list() handle, where
the prefix is already applied.
The client itself is valid within the current request only: under Swoole it returns to the pool when the coroutine ends. Stored in a property of a long-lived object, it hands one socket to every later request — precisely what the pool exists to prevent.
key()
public function key(string $name): string| Argument | Type | Default | What it does |
|---|---|---|---|
$name |
string |
— | the key name without the prefix |
Returns the full prefixed name — what the server sees. Needed wherever a key reaches
raw().
$store->key('42'); // 'session:42'
$store->key(''); // 'session:' — the prefix itselftransaction()
public function transaction(callable $callback): mixedRuns a callback against one and the same connection from start to finish.
| Argument | Type | Default | What it does |
|---|---|---|---|
$callback |
callable(\Redis): mixed |
— | receives the raw client; its result is passed back out |
Returns whatever the callback returned.
This is the one place where automatic return of the connection is not enough: MULTI and
pipeline() keep state on the connection, so every command of the sequence must
reach one socket.
$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 for you here — the callback receives the raw client, so key()
is required exactly as in raw().
A Redis transaction is not a database transaction
MULTI/EXEC guarantee that the commands run back to back with nobody else’s in
between. There is no rollback: if one command fails, the rest still apply. Conditions
have to be checked before, not after.
configClass()
public function configClass(): stringThe config class the store is bound to. No arguments. Useful for diagnostics and for
RedisPool::stats(), whose keys are config names.
What a store expects of values
With the default serializer (SERIALIZER_NONE) a value must be a string or a number:
$store->set('user', ['id' => 1]); // the string "Array" in the database, only a PHP warning
$store->get('user'); // 'Array'To store arrays and objects, 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() |
— (they send nothing) |
key() / raw() |
— |
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
- Connection pool — where the client comes from and when it goes back