Lists
A list is an ordered sequence of strings under one key. In applications it is almost always a job queue or a log of recent events. Below: what the structure is, where it belongs, and every method with its arguments and examples.
What a list is
A Redis list is a doubly linked list of strings, not an array. Everything else follows from that:
- Adding and taking from either end is O(1), whatever the length. A million elements
do not make
pushslower. - Access by index is O(N): to reach the middle Redis walks the elements.
at(500000)really does step through half a million nodes. - Order comes from insertion, not from the values. A list is never sorted and allows duplicates.
- Length goes up to 4,294,967,295 elements.
A key exists exactly as long as it holds at least one element: taking the last one
deletes the key too. The reverse holds as well — a push into a missing key creates it.
There is no separate “create a list” step, and nothing to call for it.
Elements are always strings
A list stores bytes. Arrays and objects reach it only if the config sets a
serializer; otherwise an array
turns into the string "Array" and only a PHP warning says so.
Where lists belong
A job queue. The common case. A producer appends to the tail, a consumer takes from the head — first in, first out. Redis is single-threaded, so taking is atomic: two consumers never receive the same job.
A log of the last N events. A list plus a length cap: a user’s audit trail, recent errors, an activity feed. Old entries fall off by themselves.
A buffer between something fast and something slow. A request handler drops a record in, a background worker drains it in batches. A spike does not knock the slow half over, because the list accepts faster than the worker consumes.
A stack. Add and take at the same end — LIFO. Rarer than the queue.
And where a list is the wrong choice:
| The task | Why not a list | What instead |
|---|---|---|
| Check whether an element is present | O(N) over the whole length | a set (SET) |
| Keep values unique | a list allows duplicates | a set |
| Keep things ordered by value | order comes from insertion only | a sorted set (ZSET) |
| Several consumers, each getting every message | an element goes to one of them | a stream or pub/sub |
| Keep history and replay it | a taken element is gone | a stream (XADD) |
| Read from the middle often | O(N) on every access | a hash or another structure |
The handle
$jobs = $store->list('jobs'); // the server sees 'queue:jobs'list() executes nothing — it is a view, not a request. The prefix is applied here,
once, so there is nowhere left to forget it. The handle holds no connection either:
every call asks the store for the client — except consume(), which has its own, see
below.
A handle can be created per call, but for a consumer loop keep it: then the blocking reads reuse a single connection.
Method reference
Adding
push()
public function push(mixed $value, ?int $cap = null): intAppends an element to the tail — the end pop() does not take from, which is what
makes push() + pop() a queue.
| Argument | Type | Default | What it does |
|---|---|---|---|
$value |
mixed |
— | what to store. A string or a number; anything else needs a serializer |
$cap |
?int |
null |
the maximum length of the list. null means no limit |
Returns the length after the append. With $cap, the length after trimming — never
more than $cap.
$jobs->push(json_encode(['type' => 'email', 'to' => $user->email])); // 1
$jobs->push(json_encode(['type' => 'sms'])); // 2With $cap the list trims itself — the “last N events” pattern:
foreach ($lines as $line) {
$audit->push($line, cap: 1000); // only the newest thousand is kept
}
$audit->count(); // 1000, however many lines arrivedThe cap runs as one transaction (MULTI: append, trim), so the list is never for a
moment longer than $cap — and cannot stay longer if something fails between the two
commands.
pushFront()
public function pushFront(mixed $value, ?int $cap = null): intThe same, but at the head — where pop() takes from. Two uses: building a stack, and
putting a job back at the front of the queue.
| Argument | Type | Default | What it does |
|---|---|---|---|
$value |
mixed |
— | what to store |
$cap |
?int |
null |
maximum length; the newest are kept, i.e. the first $cap |
Returns the length after the push (and the trim).
// a stack: add and take at the same end
$undo->pushFront($action);
$undo->pop(); // the last action
// an urgent job jumps the queue
$jobs->pushFront($urgentJob);Taking
pop()
public function pop(): mixedTakes an element from the head and removes it from the list. No arguments.
Returns the element, or null when the list is empty or absent. The driver reports
false there; null is chosen so that “empty” cannot be confused with a stored false.
while (($job = $jobs->pop()) !== null) {
$this->handle($job); // drain what has piled up, then leave
}The operation is atomic: however many workers call pop() at once, each element goes to
exactly one of them.
A taken element exists only in the worker's memory
If the worker dies between pop() and the end of the work, the job is gone with nothing
to show for it. When that is unacceptable, move the element instead of taking it:
moveTo().
popBack()
public function popBack(): mixedThe same, from the tail. No arguments. Returns the element or null.
$jobs->push('a');
$jobs->push('b');
$jobs->popBack(); // 'b' — the last one added
$jobs->pop(); // 'a' — the first one addedWaiting
consume()
public function consume(float $timeout = 0.0): mixedWaits for an element and takes it from the head. If one is already there, it returns immediately.
| Argument | Type | Default | What it does |
|---|---|---|---|
$timeout |
float |
0.0 |
seconds to wait. 0 waits indefinitely |
Returns the element, or null if the time ran out with the list still empty.
$jobs = $store->list('jobs');
while (!$this->stopping) {
$job = $jobs->consume(timeout: 5);
if ($job !== null) {
$this->handle($job);
}
// null just means the wait expired; check the stop flag and wait again
}
$jobs->close();The timeout is not there to avoid “waiting too long” but to hand control back to the loop periodically — otherwise the daemon would never notice a stop signal.
consume() does not take a pooled connection
A blocking command occupies its connection for the whole wait. Ten consumers with
timeout: 30 against a pool of ten would hold the entire pool for half a minute, and
every other request would fail with “no free connection” — while Redis itself sits idle,
so the incident reads as “Redis is slow”.
The handle therefore opens a connection of its own on the first consume() and
reuses it afterwards. Which is why the handle is worth keeping for the whole loop: a new
handle means a new connection.
The read timeout is raised automatically
A connection is configured to raise read error on connection when the server stays
silent longer than readTimeout (two seconds by default), and a blocking read is
precisely “the server is deliberately silent”. So consume() raises the read timeout to
cover the wait. Without that, consume(timeout: 5) would die on the second second — and
not as a timeout, but as a connection error.
close()
public function close(): voidCloses the connection consume() opened. No arguments, returns nothing. Always safe to
call: if consume() was never used, it does nothing.
Dropping the last reference to the handle closes the connection too, but a long-running daemon should not rely on that.
Moving
moveTo()
public function moveTo(self $target): mixedAtomically moves an element from the head of this list to the tail of another —
taking from where pop() takes and putting where push() puts.
| Argument | Type | Default | What it does |
|---|---|---|---|
$target |
RedisList |
— | where to move it. Must belong to the same config |
Returns the element moved, or null when the source list is empty. Throws
LogicException when the two lists belong to different configs.
This is the basis of a queue that does not lose work when a worker dies: the element is at every moment in exactly one of the two lists.
$jobs = $store->list('jobs');
$processing = $store->list('jobs:processing');
$job = $jobs->moveTo($processing);
if ($job !== null) {
$this->handle($job);
$processing->remove($job); // acknowledge: done
}What happens to jobs left in processing by a crashed worker — return them to jobs,
raise an alert, bury them as dead — is the application’s decision. The package gives the
tool and imposes no policy: only the author of a job knows what retrying it costs.
Within one config only
LMOVE runs on a single connection, so both keys must live on the same endpoint. Moving
into a list of another config is refused with an exception — without that check the
element would be written into our database under the other name, and everything
would look like it worked.
A special case is moving a list into itself: the element travels from head to tail, so the list rotates. That is how you walk a queue in a circle without losing anything:
$workers->moveTo($workers); // 'a','b','c' → 'b','c','a'Reading
Nothing in this section takes anything out.
count()
public function count(): intThe length of the list. No arguments. Returns the number of elements, 0 for a
missing key. It is O(1): Redis stores the length rather than counting it.
if ($jobs->count() > 10_000) {
$this->alert('the queue is growing faster than it is drained');
}range()
public function range(int $start = 0, int $stop = -1): arrayReturns a slice of the list, changing nothing.
| Argument | Type | Default | What it does |
|---|---|---|---|
$start |
int |
0 |
index of the first element of the slice |
$stop |
int |
-1 |
index of the last element, inclusive |
Both indexes may be negative, counting from the tail: -1 is the last, -2 the one
before it. The defaults 0, -1 mean “the whole list”. Bounds beyond the length are not
an error: the slice simply comes back shorter, or empty.
Returns an array of elements; for a missing key, an empty array.
$events->range(); // everything
$events->range(0, 9); // the first ten
$events->range(-5, -1); // the last five
$events->range(100, 200); // [] — when there are fewer elementsstop is included in the result
This differs from array_slice(): range(0, 9) returns ten elements, not nine.
That is how the LRANGE command itself works.
at()
public function at(int $index): mixedAn element by index, without taking it out.
| Argument | Type | Default | What it does |
|---|---|---|---|
$index |
int |
— | the position; a negative one counts from the tail |
Returns the element, or null when there is no such index or no such key.
$jobs->at(0); // the next job, without claiming it
$jobs->at(-1); // the last one added
$jobs->at(999); // nullRemember the O(N): cheap at the ends, expensive in the middle of a long list.
Changing
remove()
public function remove(mixed $value, int $count = 1): intRemoves elements equal to the value given. The comparison is byte-for-byte, as the server sees them.
| Argument | Type | Default | What it does |
|---|---|---|---|
$value |
mixed |
— | what to remove |
$count |
int |
1 |
how many occurrences: > 0 from the head, < 0 from the tail, 0 all of them |
Returns how many elements were actually removed.
// list: a, b, a, c, a
$list->remove('a'); // 1 → b, a, c, a (the first from the head)
$list->remove('a', -1); // 1 → b, a, c (the last from the tail)
$list->remove('a', 0); // 1 → b, c (every one left)Its main use is acknowledging work, paired with moveTo():
$processing->remove($job); // exactly one copy of this jobtrim()
public function trim(int $start, int $stop): boolKeeps only the given range and deletes everything else. Indexes work as in range() —
inclusive, negatives allowed.
| Argument | Type | Default | What it does |
|---|---|---|---|
$start |
int |
— | the first index to keep |
$stop |
int |
— | the last index to keep, inclusive |
Returns true when the command ran.
$events->trim(0, 999); // keep the first thousand
$events->trim(-100, -1); // keep the last hundredA range beyond the length empties the list
trim(5, 10) on a two-element list removes both, and the key stops existing — Redis
keeps no empty containers. That is not an error and nothing reports it, so the bounds are
worth calculating rather than guessing.
For “the last N” a separate trim() is usually unnecessary — push($value, cap: N) does
the same thing atomically.
set()
public function set(int $index, mixed $value): boolOverwrites the element at a position. The length does not change.
| Argument | Type | Default | What it does |
|---|---|---|---|
$index |
int |
— | the position; a negative one counts from the tail |
$value |
mixed |
— | the new value |
Returns true on success and false when the index does not exist. There is
deliberately no exception: the list is authoritative about its own length, so being out
of range is an answer, not a fault.
$jobs->set(0, $patchedJob);
$jobs->set(999, $x); // false, the list is unchangedThe key as a whole
Elements of a list have no lifetimes of their own — unlike hash fields. The lifetime belongs to the key.
expireKey()
public function expireKey(int $seconds): bool| Argument | Type | Default | What it does |
|---|---|---|---|
$seconds |
int |
— | after how many seconds to delete the whole key |
Returns true when the lifetime was set, false when there is no such key.
$draft->push($chunk);
$draft->expireKey(3600); // the draft lives an hourThe lifetime counts from now, not up to a moment: expireKey(time() + 3600) asks for
fifty-six years and says nothing about it.
keyTtl()
public function keyTtl(): ?intNo arguments. Returns the remaining seconds, or null — both when no lifetime is set
and when there is no key.
deleteKey()
public function deleteKey(): boolDeletes the list outright. No arguments. Returns true when the key existed.
Utility
name()
public function name(): stringThe key name with the prefix — what the server sees. Needed when reaching the list
through raw():
$store->raw()->lPos($jobs->name(), $needle); // a command the handle does not wrapconfigClass()
public function configClass(): stringThe config class the list lives on. moveTo() uses it to refuse moving an element
between different endpoints.
Mapping to Redis commands
Method names describe the intent; here is what goes to the server.
| Method | Command |
|---|---|
push(value) / push(value, cap) |
RPUSH / MULTI(RPUSH, LTRIM) |
pushFront(value) / pushFront(value, cap) |
LPUSH / MULTI(LPUSH, LTRIM) |
pop() / popBack() |
LPOP / RPOP |
consume(timeout) |
BLPOP |
moveTo(list) |
LMOVE |
count() / range() / at() |
LLEN / LRANGE / LINDEX |
remove() / trim() / set() |
LREM / LTRIM / LSET |
expireKey() / keyTtl() / deleteKey() |
EXPIRE / TTL / DEL |
Commands that are not here — LPOS, LINSERT, RPOPLPUSH, BLMPOP, LMPOP — are
reachable through raw(), remembering name().
Next
- Hashes — fields and their own lifetimes
- Stores — strings, counters,
raw()and transactions - Streams — when history and delivery tracking are needed
- Connection pool — why
consume()stays away from the pool