Redis · Stores

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.

Package flytachi/winter-redisClass RedisStoreMethods 16

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.

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 sets two things: where to go (the configuration) and under what name to live there (the prefix). After that 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. That is why a store may safely live in a field of a singleton, even though 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 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

php
public function get(string $key): mixed

Parameters

$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

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

Result on the wrong type

text
RedisCommandException: WRONGTYPE Operation against a key holding the wrong kind of value

What 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

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

Parameters

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

text
LogicException: A ttl must be a positive number of seconds; got 0.
To remove a key, call delete().

Example

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

php
public function has(string $key): bool

Parameters

$key — the key’s name without the prefix.

Returns

true if the key exists.

Example

php
$store->has('token');      // true
$store->has('nothing');    // false

delete()

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

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

Parameters

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

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

ttl()

Reports how long a key has left to live.

Syntax

php
public function ttl(string $key): ?int

Parameters

$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

php
$store->set('token', $jwt, ttl: 3600);

$store->ttl('token');      // 3600
$store->ttl('forever');    // null — the key exists, no lifetime
$store->ttl('nothing');    // null — no key

Counters

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

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

Parameters

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

php
$store->set('name', 'Alice');
$store->increment('name');
text
RedisCommandException: ERR value is not an integer or out of range

Example

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

A counter with a window

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

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

Parameters

$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

php
$store->increment('hits', 10);    // 10
$store->decrement('hits');        // 9
$store->decrement('hits', 3);     // 6

Surveying 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

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

Parameters

$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

php
$store->keys();            // ['42', 'abc']
$store->keys('user:*');    // only what matched, 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 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

php
public function flush(): int

Parameters

None.

Returns

The number of keys deleted.

Errors

LogicException — if the store has no prefix.

Example

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

A 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

php
public function hash(string $key): RedisHash

Parameters

$key — the key’s name without the prefix.

Returns

A RedisHash bound to the full key name.

Example

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

php
public function list(string $key): RedisList

Parameters

$key — the key’s name without the prefix.

Returns

A RedisList bound to the full key name.

Example

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

php
public function stream(string $key): RedisStream

Parameters

$key — the key’s name without the prefix.

Returns

A RedisStream bound to the full key name.

Example

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

php
public function raw(): \Redis

Parameters

None.

Returns

A \Redis object — the ext-redis client, already connected and on the right database.

Example

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

php
public function key(string $name): string

Parameters

$name — the key’s name without the prefix.

Returns

The string “prefix + name”.

Example

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

transaction()

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

php
public function transaction(callable $callback): mixed

Parameters

$callback — a function receiving the raw \Redis client. Everything it calls goes over one connection.

Returns

Whatever the callback returned, unchanged.

Example

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

php
public function configClass(): string

Parameters

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.

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

To 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