Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

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 Store gives you typed shared memory across all workers
  • When Counter beats Store for atomic integer state
  • How this compares to Redis — and when to still reach for Redis

The workers don’t share a heap

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' => [\OpenSwoole\Table::TYPE_INT,    4],
    'reset' => [\OpenSwoole\Table::TYPE_INT,    4],
    'note'  => [\OpenSwoole\Table::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

ToolBest forLives where
CounterOne global integer (visits, queue depth, retry count)Shared memory, in-process
StoreKeyed rows with typed columns (rate-limit tables, room state, hot caches)Shared memory, in-process
SessionsPer-user data (cart, login state, preferences)Disk files, keyed by cookie
RedisState that must survive a deploy / span multiple hostsNetwork

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' => [\OpenSwoole\Table::TYPE_INT, 4],
    'reset' => [\OpenSwoole\Table::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

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

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.
  • Counter is a lock-free atomic integer for the simple "one global counter" case.
  • Allocate both before $app->run() so the master shares them on fork.
  • Use Redis only when you outgrow in-process: multi-host, surviving restarts, external consumers.