Sharing State: Store & Counter
Workers don't share memory. Workers share a fridge.
You will learn
- Why each worker has its own heap — and why that's usually a feature
- How
Storegives you typed shared memory across all workers - When
CounterbeatsStorefor atomic integer state - How this compares to Redis — and when to still reach for Redis
The workers don't share a heap
graph TD
M[Master process] -->|fork| W1[Worker 1
own PHP heap]
M -->|fork| W2[Worker 2
own PHP heap]
M -->|fork| W3[Worker N
own PHP heap]
M -->|allocate before fork| SHM[Store shared memory
all workers read and write]
W1 -->|set / get / incr| SHM
W2 -->|set / get / incr| SHM
W3 -->|set / get / incr| SHM
SHM -.->|read| W1
SHM -.->|read| W2
SHM -.->|read| W3
style SHM fill:#fffbeb,stroke:#f59e0b,stroke-width:2px
style M fill:#ecfdf5,stroke:#059669,stroke-width:2px
style W1 fill:#fef2f2,stroke:#f87171
style W2 fill:#fef2f2,stroke:#f87171
style W3 fill:#fef2f2,stroke:#f87171
ZealPHP runs N worker processes (default: number of CPU cores). Each worker is a separate OS process with its own PHP heap. A static class property in one worker is invisible to the other workers. Same for any object you create — it lives in one worker's memory.
That's usually fine. Most request state belongs to one request, handled by one worker — isolation prevents cross-talk. But sometimes you genuinely want shared state: a hit counter, a rate-limiter, a cache of expensive lookups, a session of active WebSocket rooms. You need a fridge: a place outside the kitchens where every cook can drop something in and every cook can take something out.
Store: typed shared memory
ZealPHP\Store wraps OpenSwoole's shared-memory Table. You
declare a schema, the framework allocates a fixed-size block in shared memory (mmap), and
every worker can read/write rows by key:
// In app.php, BEFORE $app->run():
use ZealPHP\Store;
Store::make('rate_limits', 10000, [
'count' => [Store::TYPE_INT, 4],
'reset' => [Store::TYPE_INT, 4],
'note' => [Store::TYPE_STRING, 64],
]);
Once registered, any worker can interact with the table by name:
// In a route handler — this works from any worker:
Store::set('rate_limits', $userIp, [
'count' => 1,
'reset' => time() + 60,
'note' => 'api',
]);
$row = Store::get('rate_limits', $userIp); // read whole row
$count = Store::get('rate_limits', $userIp, 'count'); // read one field
$now = Store::incr('rate_limits', $userIp, 'count'); // atomic +1
$gone = Store::del('rate_limits', $userIp);
$how_many = Store::count('rate_limits');
Lifecycle: make tables BEFORE run()
Shared memory is allocated in the master process, then inherited by every worker on fork.
That means you must call Store::make() before $app->run().
If you try to make a table inside a request handler, only the current worker sees it —
the others have no idea it exists.
Same applies to Counter (next section). The rule of thumb: anything that
should outlive a single request, register in app.php at boot.
Counter: lock-free atomic integers
For the common case of "I just need to count something across workers," Counter
is a simpler primitive — one integer, atomic operations, no table schema:
use ZealPHP\Counter;
// In app.php, before run():
$visits = new Counter(0); // initial value
// In a handler (or any worker):
$visits->increment(); // atomic +1, returns new value
$visits->decrement(2); // atomic -2
$visits->get(); // read current value
$visits->set(1000); // overwrite
$ok = $visits->compareAndSet(1000, 0); // atomic CAS — reset only if value is exactly 1000
Under the hood, Counter wraps OpenSwoole\Atomic. Lock-free, no
kernel hop, no syscall per operation — faster than incrementing a Redis key by a couple
of orders of magnitude.
Store vs Counter vs sessions vs Redis
| Tool | Best for | Lives where |
|---|---|---|
Counter | One global integer (visits, queue depth, retry count) | Shared memory, in-process |
Store | Keyed rows with typed columns (rate-limit tables, room state, hot caches) | Shared memory, in-process |
| Sessions | Per-user data (cart, login state, preferences) | Disk files, keyed by cookie |
| Redis | State that must survive a deploy / span multiple hosts | Network |
The first three are free — you don't pay an extra-service tax. Redis is for the cases where in-process shared memory isn't enough: multi-machine deployments, hot data that must outlive a restart, queues consumed by external workers. Don't reach for Redis when Store will do.
A complete example: per-IP rate limit
// app.php
Store::make('rate', 100000, [
'count' => [Store::TYPE_INT, 4],
'reset' => [Store::TYPE_INT, 4],
]);
$app->route('/api/expensive', function ($request) {
$ip = $request->server['remote_addr'];
$now = time();
$row = Store::get('rate', $ip);
if (!$row || $row['reset'] < $now) {
Store::set('rate', $ip, ['count' => 1, 'reset' => $now + 60]);
return ['ok' => true, 'remaining' => 9];
}
if ($row['count'] >= 10) return 429;
$new = Store::incr('rate', $ip, 'count');
return ['ok' => true, 'remaining' => 10 - $new];
});
Ten requests per minute per IP, shared across all workers, with zero external infrastructure. The same logic with Redis is more lines, more failure modes, and one more service to keep alive at 3 AM.
Try it live
- Store: write → read across workers
- Store: atomic increment — refresh in another tab to confirm
- Counter: lock-free atomic int
- /store — full reference docs
You write Store::make('cache', 100, [...]) inside a route handler. The next request hits a different worker. What happens when that worker calls Store::get('cache', $key)?
Going cross-node: pluggable Redis backend
Workers in one process tree share Store via shared memory. Two ZealPHP servers
on different machines don’t — their Store instances are completely
separate. When you actually need cross-node visibility (a chat app where users hit different
load-balanced servers, an admin dashboard summing counters from N hosts), flip
Store to the Redis backend with one line. Use the enum form for
type-safety + IDE autocomplete:
use ZealPHP\Store;
// app.php — before $app->run()
Store::defaultBackend(Store::BACKEND_REDIS); // ZEALPHP_REDIS_URL env
// or explicit:
Store::defaultBackend(Store::BACKEND_REDIS, [
'url' => 'redis://cache.internal:6379/0',
]);
// Bare string still works for BC (also: ZEALPHP_STORE_BACKEND=redis env):
Store::defaultBackend(Store::BACKEND_REDIS);
// Every existing Store::set / get / incr / count call now routes to Redis.
// Counter::defaultBackend follows automatically when the env var is used.
Backend-pluggable means every existing handler keeps working unchanged. The trade-off:
Redis is ~50µs loopback vs OpenSwoole\Table’s ~ns — orders of
magnitude slower, but cross-node and persistent (with AOF/RDB). Pick Table for ns hot paths,
Redis when you need the cross-node guarantee. Both phpredis (preferred when
ext-redis is loaded) and predis SUBSCRIBE loops yield correctly under
HOOK_ALL — either driver is production-validated.
Want both? Tiered backend (L1 Table + L2 Redis)
Store::BACKEND_TIERED pairs a TableBackend (L1, ns latency, bounded-staleness
via l1_ttl) with a RedisBackend (L2, source of truth, cross-node). Reads return
L1 if fresh, else fetch L2 + populate L1. Writes write-through to L2 + refresh L1. Optional
HMAC-signed cross-node L1 invalidation keeps every node’s L1 in sync sub-millisecond.
Store::defaultBackend(Store::BACKEND_TIERED, [
'url' => 'redis://cache:6379',
'l1_ttl' => 5, // L1 freshness window (seconds)
'invalidation_secret' => getenv('ZEALPHP_TIERED_INVALIDATION_SECRET') ?: null,
]);
The invalidation_secret is shared across every node in the cluster so peers
verify each other’s evictions (without it, anyone with Redis access could DoS the
cluster’s L1). Same secret everywhere, or peers reject each other’s messages.
See /store#backends for the full comparison table +
/store#backend-memory for the Table RAM-cost math.
Cache::getOrCompute — the canonical read-through helper
Cache — Store’s higher-level cousin with a memory tier + file tier — ships
a getOrCompute() helper that collapses the "miss-then-compute-then-store"
boilerplate into one call:
// Before:
$users = Cache::get('users:active');
if ($users === null) {
$users = DB::select(...); // expensive
Cache::set('users:active', $users, ttl: 60);
}
// After:
$users = Cache::getOrCompute('users:active', fn() => DB::select(...), ttl: 60);
Null is cached as a valid stored value (sentinel-based miss detection — "stored null" is distinct from "no key"). Useful when a lookup legitimately returns null and you don’t want to re-execute it on every request.
Cross-node messaging: pub/sub + Streams
Redis backend also unlocks two new public primitives for cross-worker AND cross-host
messaging. Both are no-ops on the Table backend (they throw a clear
StoreException):
// Fire-and-forget pub/sub (best-effort, ~0.5ms loopback)
App::subscribe('chat:room:42', function (string $payload, string $channel) {
// every worker on every server with this handler runs the callback
// — perfect for fan-out broadcast to local WebSocket fds.
});
$receivers = Store::publish('chat:room:42', json_encode($message));
// Reliable at-least-once via Redis Streams (consumer groups)
App::subscribeReliable('orders', function (string $payload, string $id, string $stream): bool {
return processOrder($payload); // true → XACK; false/throw → leave pending
});
$messageId = Store::publishReliable('orders', json_encode($order));
Pick publish for cache invalidation, WebSocket fan-out, presence beats —
drops are tolerable. Pick publishReliable for command/event sourcing, work
queues, anything that must not drop. Both require the Redis backend; pattern channels
('chat:*') and pattern handlers (PSUBSCRIBE) work transparently. See
/store#pubsub for the full comparison and the production driver
note.
Key Takeaways
- Workers don't share PHP heaps — isolation by default, sharing by opt-in.
Store::make()allocates a typed, fixed-size table in shared memory — every worker can read/write rows by key.Counteris a lock-free atomic integer for the simple "one global counter" case.- Allocate both before
$app->run()so the master shares them on fork. - Need cross-node? One line:
Store::defaultBackend(Store::BACKEND_REDIS)— every existing handler routes to Redis with zero changes. - Cross-node messaging:
Store::publish+App::subscribefor fire-and-forget,Store::publishReliable+App::subscribeReliablefor at-least-once.