Redis · Stores

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.

main/Stores/SessionStore.php
<?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:

php
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

php
$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()

php
public function get(string $key): mixed

Reads 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.

php
$store->get('token');      // 'abc123'
$store->get('nothing');    // null

If you need to store false itself

A stored false comes back as nullget() cannot tell them apart. Store such values encoded ('0'/'1'), or check existence separately with has().

set()

php
public function set(string $key, mixed $value, ?int $ttl = null): bool

Writes 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”.

php
$store->set('token', $jwt);               // forever
$store->set('token', $jwt, ttl: 3600);    // for an hour
$store->set('token', $jwt, ttl: 0);       // LogicException

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 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:

php
$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()

php
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()

php
public function delete(string ...$keys): int

Deletes 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.

php
$store->delete('token');                  // 1
$store->delete('token');                  // 0 — already gone
$store->delete('a', 'b', 'c');            // 2, when 'c' was not there

Deleting 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()

php
public function increment(string $key, int $by = 1): int
public function decrement(string $key, int $by = 1): int

Change 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.

php
$store->increment('hits');        // 1
$store->increment('hits', 10);    // 11
$store->decrement('hits');        // 10

This 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.

php
// 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();
}
php
$store->set('name', 'Alice');
$store->increment('name');    // RedisCommandException: value is not an integer

Such a call used to return 0 — a refusal that looked like “the counter reset”. Now it is an exception.

ttl()

php
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().

php
$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 key

Surveying the contents

keys()

php
public function keys(string $pattern = '*'): array

Lists 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.

php
$store->keys();            // ['42', 'abc']
$store->keys('user:*');    // only what matches, still inside the store

This 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()

php
public function flush(): int

Deletes every key of the store. No arguments.

Returns how many keys were removed. Throws LogicException when the store has no prefix.

php
$sessions->flush();     // 128 — neighbouring stores untouched

Internally 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()

php
public function hash(string $key): RedisHash
public function list(string $key): RedisList
public function stream(string $key): RedisStream

Return 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.

php
$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()

php
public function raw(): \Redis

Returns 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.

php
$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()

php
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().

php
$store->key('42');      // 'session:42'
$store->key('');        // 'session:' — the prefix itself

transaction()

php
public function transaction(callable $callback): mixed

Runs 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.

php
$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()

php
public function configClass(): string

The 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:

php
$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