{
  "id": "php",
  "title": "Redis feature store with Predis",
  "url": "https://redis.io/docs/latest/develop/use-cases/feature-store/php/",
  "summary": "Build a Redis-backed online feature store in PHP with Predis",
  "tags": [
    "docs",
    "develop",
    "stack",
    "oss",
    "rs",
    "rc"
  ],
  "last_updated": "2026-06-04T14:49:57+01:00",
  "children": [
    {
      "id": "demo_template",
      "summary": "",
      "title": "",
      "url": "https://redis.io/docs/latest/develop/use-cases/feature-store/php/demo_template/"
    }
  ],
  "page_type": "content",
  "content_hash": "bc876b09fc0e3e5d90a4e9ac3ba711d31392cd39e6f78c0ddad77e93abe925a4",
  "sections": [
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "This guide shows you how to build a small Redis-backed online feature store\nin PHP with [Predis](https://redis.io/docs/latest/develop/clients/php). The demo runs\non top of PHP's built-in development server (`php -S`) and uses a detached\nCLI process for the streaming worker, so you can bulk-load a batch of users\nwith a key-level TTL, watch real-time features expire per-field via\n`HEXPIRE`, retrieve any subset of features for one user under 2 ms, and\npipeline `HMGET` across a hundred users for batch scoring."
    },
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "Each entity (here, a user) is one Redis\n[Hash](https://redis.io/docs/latest/develop/data-types/hashes) at a deterministic key —\n`fs:user:{id}`. The hash holds every feature for that entity as one field per\nfeature: batch-materialized aggregates (refreshed once a day) alongside\nstreaming-updated signals (refreshed every few seconds). One\n[`HMGET`](https://redis.io/docs/latest/commands/hmget) returns whichever subset the\nmodel needs in one network round trip.\n\nTwo TTL layers solve the *mixed staleness* problem without an\napplication-side cleaner:\n\n* A **key-level** [`EXPIRE`](https://redis.io/docs/latest/commands/expire) aligned with\n  the batch materialization cycle (24 hours in the demo). If the batch\n  refresher fails, the whole entity disappears at the next cycle and\n  inference sees a missing entity — which the model handler can detect and\n  fall back on — rather than silently outdated values.\n* A **per-field** [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) (Redis 7.4+)\n  on each streaming feature gives that field its own shorter expiry,\n  independent of the rest of the hash. If the streaming pipeline stops\n  updating a feature, the field self-cleans while the batch fields stay\n  populated.\n\nThat gives you:\n\n* A single round trip for retrieval — any subset of features for one entity\n  in one [`HMGET`](https://redis.io/docs/latest/commands/hmget).\n* Sub-millisecond hot path. The Redis-side work is microseconds; in\n  practice the bottleneck is the network round trip plus the model's own\n  feature-prep.\n* Pipelined batch scoring — one round trip for `N` users at once.\n* Independent freshness per feature, expressed as a server-side TTL rather\n  than as application logic.\n* Self-cleanup on pipeline failure: a stalled batch refresher lets entities\n  expire on schedule, and a stalled streaming worker lets each affected\n  field expire on its own timer."
    },
    {
      "id": "how-php-s-request-model-shapes-the-demo",
      "title": "How PHP's request model shapes the demo",
      "role": "content",
      "text": "PHP's hosting model is different from every other client in this use case.\n`php -S` gives each request a fresh PHP execution context, so a long-lived\nstreaming worker can't live inside the demo router the way it does in\nPython, Node.js, Go, or Java. The demo handles this by spawning the\nstreaming worker as a **separate, detached CLI process** the first time\nthe demo server is hit. The router and the worker then share state through\nRedis itself:\n\n* `fs:control:worker_pid` — PID of the running worker. The router checks\n  it on every request and respawns the worker if the PID is no longer\n  alive.\n* `fs:control:paused` — `1` while paused, `0` otherwise. The worker polls\n  this between ticks.\n* `fs:control:tick_in_flight` — set by the worker *before* each tick and\n  cleared after. The router's `/reset` handler waits for this to flip to\n  `0` before it issues the `DEL` sweep.\n* `fs:control:tick_count` / `fs:control:writes_count` — counters the\n  router reads to populate the UI.\n* `fs:control:stop` — graceful-shutdown flag the worker checks each tick.\n\nThis is the same race-free pause-and-wait-idle pattern as every other\nclient; it's just implemented through Redis primitives because there's no\nshared memory between the router and the worker.\n\nPredis-specific notes:\n\n* Predis 3 ships typed `hexpire()` and `httl()` methods. The helper uses\n  them directly. `HEXPIRE` returns one status code per field (`1` set,\n  `2` deleted because TTL was 0/past, `0` conditional flag not met, `-2`\n  no such field/key).\n* Predis 3's `hset()` accepts variadic `field, value, field, value, ...`\n  pairs but **not** a single field=>value map argument the way Predis 2\n  did. The helper flattens the encoded map and spreads it:\n  `$pipe->hset($key, ...$flat)`."
    },
    {
      "id": "how-it-works",
      "title": "How it works",
      "role": "content",
      "text": "There are three paths: a **batch path** that bulk-loads features once per\nmaterialization cycle, a **streaming path** that updates real-time features\nas events arrive, and an **inference path** that reads features on the\nrequest side."
    },
    {
      "id": "batch-path-per-materialization-cycle",
      "title": "Batch path (per materialization cycle)",
      "role": "content",
      "text": "1. The batch job calls `BuildFeatures::synthesizeUsers($count, $seed)`\n   (in production, the equivalent computation lives in an offline\n   pipeline against the warehouse). The result is\n   `array<string, array<string, mixed>>` keyed by user ID.\n2. `$store->bulkLoad($rows, $ttlSeconds)` queues one\n   [`HSET`](https://redis.io/docs/latest/commands/hset) plus one\n   [`EXPIRE`](https://redis.io/docs/latest/commands/expire) per user through\n   `$redis->pipeline(function ($pipe) { ... })`, so the whole batch ships\n   in a single round trip."
    },
    {
      "id": "streaming-path-per-tick",
      "title": "Streaming path (per tick)",
      "role": "content",
      "text": "The detached `streaming_worker.php` process polls Redis once per tick and\ncalls `$store->updateStreaming($userId, $fields)` for a handful of random\nusers. That queues:\n\n1. An [`HSET`](https://redis.io/docs/latest/commands/hset) writing the new field\n   values. Redis is single-threaded per shard, so this is atomic against\n   any concurrent batch write on the same hash — no version columns, no\n   locks.\n2. An [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) over exactly the\n   fields that were written, with the streaming TTL. Each streaming field\n   carries its own per-field expiry independent of the rest of the hash.\n   Pause the worker (or stop it entirely) and these fields drop out one\n   by one as their TTLs elapse, while the batch fields remain populated\n   under the longer key-level TTL."
    },
    {
      "id": "inference-path-per-http-request",
      "title": "Inference path (per HTTP request)",
      "role": "content",
      "text": "1. The model server picks the feature subset it needs (the schema is\n   owned by the model, not the store).\n2. It calls `$store->getFeatures($userId, $names)`, which is one\n   [`HMGET`](https://redis.io/docs/latest/commands/hmget). Redis returns the values\n   in the same order as the requested fields, with `null` for any field\n   that doesn't exist (or has expired).\n3. For batch inference, the model server calls\n   `$store->batchGetFeatures($userIds, $names)`, which pipelines one\n   [`HMGET`](https://redis.io/docs/latest/commands/hmget) per user across all `N`\n   users in a single network round trip."
    },
    {
      "id": "project-layout",
      "title": "Project layout",
      "role": "content",
      "text": "[code example]\n\nRun the demo with `composer install && composer start`, or directly:\n`php -S 127.0.0.1:8094 demo_server.php`."
    },
    {
      "id": "the-feature-store-helper",
      "title": "The feature-store helper",
      "role": "content",
      "text": "The `FeatureStore` class wraps the read/write paths\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/php/FeatureStore.php)):\n\n[code example]"
    },
    {
      "id": "data-model",
      "title": "Data model",
      "role": "content",
      "text": "Each user is one Redis Hash. Every value is stored as a string — Redis\nhash fields are bytes on the wire, so the helper encodes booleans as\n`'true'` / `'false'` (`FeatureStore::encodeValue()`) and uses\n`(string)$value` for everything else. The model server is responsible\nfor parsing back to the right type, the same way it would when reading\nany serialized feature store.\n\n[code example]"
    },
    {
      "id": "bulk-loading-batch-features",
      "title": "Bulk-loading batch features",
      "role": "content",
      "text": "`bulkLoad` queues one `HSET` and one `EXPIRE` per user through\n`$redis->pipeline(...)`, so the whole batch ships in a single round trip.\n\n[code example]\n\n`$redis->pipeline(callable)` is a non-transactional batch: commands queue\nup and ship in one round trip but they don't run inside a `MULTI/EXEC`\nblock. That's the right choice here because each user's `HSET` +\n`EXPIRE` pair is independent of every other user's. For the rare case\nwhere the pair has to be inseparable, use `$redis->transaction(...)` (or\na Lua script via [`EVAL`](https://redis.io/docs/latest/commands/eval) /\n[Eval scripting](https://redis.io/docs/latest/develop/programmability/eval-intro)).\n\nIn production, the equivalent of this script runs as an offline pipeline\n(a Spark or Feast `materialize` job) that reads from the warehouse and\nwrites into Redis. The\n[Feast `RedisOnlineStore`](https://docs.feast.dev/reference/online-stores/redis)\nprovider does exactly this under the hood; the in-house\n[Redis Feature Form](https://redis.io/docs/latest/develop/ai/featureform) integration\ncovers the materialize + serve path end-to-end."
    },
    {
      "id": "streaming-writes-with-per-field-ttl",
      "title": "Streaming writes with per-field TTL",
      "role": "content",
      "text": "`updateStreaming` is the linchpin of the mixed-staleness story:\n\n[code example]\n\n[`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) sets the TTL on\n*individual* hash fields, not on the whole key. The two commands are\nqueued in the same `pipeline` callback so Redis runs them in order: the\n`HSET` first creates or overwrites the fields, then `HEXPIRE` attaches a\nTTL to each of those same fields. `hexpire()` returns one status code\nper field:\n\n* `1` — TTL set / updated.\n* `2` — the expiry was 0 or in the past, so Redis deleted the field\n  instead of applying a TTL.\n* `0` — an `NX | XX | GT | LT` conditional flag was specified and not\n  met (we never use one here).\n* `-2` — no such field, or no such key.\n\nThe helper throws if any code is anything other than `1`, so the \"every\nstreaming write renews its TTL\" invariant fails loudly rather than\nsilently leaving a streaming field with no expiry attached.\n\nIf a streaming pipeline stops, the streaming fields drop out one by one\nas their per-field TTLs elapse. `fieldTtlsSeconds` (which wraps `httl()`)\nlets the model side inspect the remaining TTL on any field — useful\nboth for debugging and as a freshness signal in the model itself.\n\n> **HEXPIRE requires Redis 7.4 or later.** `HEXPIRE` and the field-level\n> TTL commands were added in Redis 7.4. Predis 3.0 was the first major\n> release with the typed bindings; the demo's `composer.json` pins\n> `^3.0`."
    },
    {
      "id": "inference-reads-with-hmget",
      "title": "Inference reads with HMGET",
      "role": "content",
      "text": "`getFeatures` is one `HMGET`:\n\n[code example]\n\nThe model knows exactly which features it consumes, so the request path\nalways takes the `hmget` branch with an explicit field list — that's\nthe sub-millisecond path. `hgetall` is the right call for debugging\n(which is what the demo's \"Inspect\" panel does) but not for serving:\nit forces Redis to serialize every field, including ones the model\ndoesn't need.\n\nFields that don't exist (because they were never written, or because\nthey expired) come back as `null`. The helper drops them from the\nresult array so the caller sees only the features that are actually\navailable."
    },
    {
      "id": "batch-scoring-with-pipelined-hmget",
      "title": "Batch scoring with pipelined HMGET",
      "role": "content",
      "text": "For batch inference, the same `HMGET` shape pipelines across users:\n\n[code example]\n\nOne round trip for the whole batch. The demo regularly returns a\n30-user batch in ~1 ms against a local Redis.\n\nA Redis Cluster is different: a single `pipeline()` block ships through\none connection to one node. For batch reads on a cluster, configure\nPredis with a cluster connection profile and fan out parallel\nnon-pipelined `hmget` calls (the cluster client routes each one to the\nright shard), or group entity IDs by hash slot and run one pipeline\nagainst each shard's node-connection in parallel. A hash tag like\n`fs:user:{vip}:u0001` forces a known set of keys onto the same shard\nso one pipeline can cover them all in a single round trip."
    },
    {
      "id": "the-streaming-worker",
      "title": "The streaming worker",
      "role": "content",
      "text": "`streaming_worker.php` is a small CLI shim that loads `StreamingWorker`\nand runs its tick loop until the demo server flips\n`fs:control:stop` to `1` (or SIGTERM lands). The class itself lives in\n`StreamingWorker.php`\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/php/StreamingWorker.php)):\n\n[code example]\n\nThe pre-flight `tick_in_flight = 1` before the pause check, and the\nouter `finally` block that clears every control key on every exit\npath, are the same correctness levers as every other client in this\nuse case. The only difference is that the flags live in Redis rather\nthan in process memory.\n\nThe demo server's `/reset` handler reads the same Redis keys: it sets\n`fs:control:paused = 1`, polls `fs:control:tick_in_flight` until it\nsees `0`, then issues the `DEL` sweep. That's the cross-process\nequivalent of `worker.pause() + worker.wait_for_idle()` in the\nsingle-process clients.\n\n`demo_server.php` spawns the worker on the first request with\n`nohup ... &` (detached so it survives the per-request `php -S`\nprocess) and checks `pid_alive($pid)` on every subsequent request.\nIf the worker has died, it's respawned on the next request.\n\nTo shut the worker down cleanly from outside the demo (the detached\nprocess isn't tied to the foreground `php -S`), flip the stop flag\nwith `redis-cli`:\n\n[code example]\n\nThe worker's tick loop checks `fs:control:stop` at the top of every\niteration and exits, clearing every `fs:control:*` key on the way\nout so the next demo run starts from a clean slate."
    },
    {
      "id": "the-batch-builder",
      "title": "The batch builder",
      "role": "content",
      "text": "`build_features.php` is the demo's nightly materializer\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/php/build_features.php)).\nIt generates synthetic feature rows and calls `$store->bulkLoad()`\nonce. The synthesis itself is not the point — in a real deployment the\nequivalent code reads from the offline store (Snowflake, BigQuery,\nIceberg) and writes the resulting hashes into Redis.\n\nRun the builder on its own (independently of the demo server) to\npopulate Redis from the command line:\n\n[code example]\n\nThat writes 500 users at `fs:user:*` with a one-hour key-level TTL,\nwhich is how a typical operator would pre-seed a feature store from\nthe command line when debugging."
    },
    {
      "id": "the-interactive-demo",
      "title": "The interactive demo",
      "role": "content",
      "text": "`demo_server.php` runs as a router script under `php -S` on port 8094.\nThe HTML page (loaded via `file_get_contents` from\n`demo_template.html`) lets you:\n\n* **Bulk-load** any number of users (default 200) with a configurable\n  key-level TTL.\n* See the **store state**: user count, batch / streaming TTLs,\n  cumulative read/write counters.\n* See the **streaming worker** status and **pause or resume** it. The\n  pause flag goes into Redis at `fs:control:paused`; the detached\n  worker process reads it between ticks.\n* Run an **inference read** for any user with a chosen feature subset,\n  and see the value, the per-field TTL, and the read latency.\n* Run **batch scoring** with a pipelined `HMGET` across `N` users.\n* **Inspect** any user's full hash with field-level TTLs and the\n  key-level TTL.\n\nEndpoints:\n\n| Endpoint                  | What it does                                                                        |\n|---------------------------|-------------------------------------------------------------------------------------|\n| `GET  /state`             | User count, TTL config, stats counters, worker status.                              |\n| `POST /bulk-load`         | Pipelined `HSET` + `EXPIRE` over N synthetic users with a chosen TTL.               |\n| `POST /worker/toggle`     | Pause / resume the streaming worker.                                                |\n| `POST /read`              | `HMGET` a chosen feature subset for one user; report latency and per-field TTLs.    |\n| `POST /batch-read`        | Pipeline `HMGET` across N users; report total latency and per-entity field counts.  |\n| `GET  /inspect`           | `HGETALL` + `HTTL` for one user; full hash view with per-field TTLs.                |\n| `POST /reset`             | Drop every user under the key prefix (used by the demo's reset button).             |\n\n> **PHP's `$_POST` doesn't preserve repeated keys.** The demo's `/read`\n> and `/batch-read` handlers parse the raw `php://input` body\n> manually via `parse_multi_form()` so the model can request several\n> features in one call (`field=a&field=b&field=c`). PHP's built-in\n> form-parsing would keep only the last `field=` value."
    },
    {
      "id": "prerequisites",
      "title": "Prerequisites",
      "role": "content",
      "text": "* **Redis 7.4 or later.** [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire)\n  and [`HTTL`](https://redis.io/docs/latest/commands/httl) were added in Redis 7.4;\n  the demo relies on per-field TTL for the mixed-staleness story.\n* **PHP 8.1 or later.** The demo uses readonly properties, named\n  arguments, and first-class callable syntax.\n* **Predis 3.0 or later.** The demo's `composer.json` pins `^3.0`.\n  Typed bindings for the field-TTL commands ship from 3.0.\n* **A POSIX shell environment** for the worker spawn (`nohup`,\n  `posix_kill`). The demo has been tested on macOS and Linux; Windows\n  would need a different process-detach approach.\n\nIf your Redis server is running elsewhere, start the demo with\n`REDIS_URI=tcp://host:port php -S 127.0.0.1:8094 demo_server.php`."
    },
    {
      "id": "running-the-demo",
      "title": "Running the demo",
      "role": "content",
      "text": ""
    },
    {
      "id": "get-the-source-files",
      "title": "Get the source files",
      "role": "content",
      "text": "The demo lives in a small Composer project under\n[`feature-store/php`](https://github.com/redis/docs/tree/main/content/develop/use-cases/feature-store/php).\nClone the repo or copy the directory:\n\n[code example]"
    },
    {
      "id": "start-the-demo-server",
      "title": "Start the demo server",
      "role": "content",
      "text": "From the project directory:\n\n[code example]\n\nThe first request to the server triggers the one-time bootstrap\n(reset + seed the store, spawn the streaming worker). You should see:\n\n[code example]\n\nOpen [http://127.0.0.1:8094](http://127.0.0.1:8094). Useful things to\ntry:\n\n* Pick a user and click **Read features** with a mixed batch/streaming\n  subset — you'll see batch fields with no per-field TTL (covered by\n  the key-level TTL) and streaming fields with a positive per-field\n  TTL.\n* Click **Pipeline HMGET** with `count=100` to see the latency of a\n  100-user batch read.\n* Click **Pause / resume** on the streaming worker and leave it\n  paused for ~5 minutes (or restart the server with\n  `STREAMING_TTL_SECONDS=30` to make it visible in seconds). Re-run\n  **Read features** on any user and watch the streaming fields\n  disappear while the batch fields stay.\n* Click **Inspect** on a user to see the full hash with field-level\n  TTLs.\n* Click **Reset** to drop every user and start over."
    },
    {
      "id": "production-usage",
      "title": "Production usage",
      "role": "content",
      "text": "The guidance below focuses on the production concerns specific to\nrunning a feature store on Redis. For the generic Predis production\nchecklist — connection options,\n[transactions and pipelining](https://redis.io/docs/latest/develop/clients/php/transpipe),\nTLS, AUTH, error handling — see the\n[Predis client guide](https://redis.io/docs/latest/develop/clients/php) and the\n[connect recipe](https://redis.io/docs/latest/develop/clients/php/connect)."
    },
    {
      "id": "don-t-run-php-s-in-production",
      "title": "Don't run `php -S` in production",
      "role": "content",
      "text": "The built-in PHP development server is single-threaded and not\nproduction-grade. A real deployment runs PHP-FPM behind nginx or\nApache, with the streaming worker as a separate systemd / supervisord /\nKubernetes-cron-job process. The router script in `demo_server.php` is\nshaped for the demo; for production, extract the route handlers into a\nproper PHP framework (Symfony, Laravel, Slim) that pools `Predis\\Client`\nconnections per-worker."
    },
    {
      "id": "pick-the-batch-ttl-to-outlast-a-failed-refresher",
      "title": "Pick the batch TTL to outlast a failed refresher",
      "role": "content",
      "text": "The whole-entity `EXPIRE` is your safety net against silent staleness\nfrom a broken batch pipeline. Set it longer than your worst-case batch\noutage so a single missed run doesn't take the feature store offline,\nbut short enough that a sustained outage causes loud failures (missing\nentities) rather than quiet ones (yesterday's features being scored as\ntoday's). The standard choice is one cycle of \"expected refresh\ninterval × 2\" — for a daily batch, 48 hours; for a 6-hour batch, 12\nhours.\n\nThe same logic applies to the per-field streaming TTL: a few times the\nexpected update interval so a slow-but-alive streaming worker doesn't\nchurn features needlessly, but short enough that a stalled worker\ncauses visible freshness failures."
    },
    {
      "id": "co-locate-the-online-store-with-serving-not-with-training",
      "title": "Co-locate the online store with serving, not with training",
      "role": "content",
      "text": "The online store's hash representation does *not* have to match the\nschema in your offline store. The batch materialization step is your\nchance to flatten joins, encode categoricals, and project to whatever\nshape the model server wants — so the request path is exactly one\n`HMGET` and zero transforms.\n\nThe training pipeline reads from the offline store with its own\nschema; the serving pipeline reads from Redis with the flattened\nserving schema. Keeping those two pipelines as the same code path is\nwhat prevents training-serving skew."
    },
    {
      "id": "run-the-streaming-worker-as-a-real-process-supervisor",
      "title": "Run the streaming worker as a real process supervisor",
      "role": "content",
      "text": "The demo spawns the worker with `nohup ... &` because it's the\nsimplest portable thing that works under `php -S`. In production,\nmanage the worker process with systemd / supervisord / Kubernetes —\nsomething that restarts it on crash, captures its logs properly, and\ngives you a clean shutdown path. The Redis-backed `fs:control:*`\nstate (pause flag, in-flight flag, counters) keeps working\nunchanged — that's the point of putting it in Redis."
    },
    {
      "id": "make-hexpire-part-of-every-streaming-write",
      "title": "Make HEXPIRE part of every streaming write",
      "role": "content",
      "text": "The single biggest correctness lever in this design is that the\nstreaming write applies `HEXPIRE` *every time*. If a streaming worker\nwrites a field without renewing its TTL, the field carries whatever\nexpiry was there before — possibly none, possibly stale — and the\nmixed-staleness invariant breaks. Keep the `HSET` and `HEXPIRE` in\nthe same pipeline (or, even safer, in the same\n[Lua script](https://redis.io/docs/latest/develop/programmability/eval-intro) if\nyou don't trust the call site)."
    },
    {
      "id": "avoid-hgetall-on-the-request-path",
      "title": "Avoid HGETALL on the request path",
      "role": "content",
      "text": "`HGETALL` reads every field on the hash, including ones the model\ndoesn't need. With dozens of features per entity, that is wasted\nserialization work on the server and wasted bandwidth on the wire.\nAlways specify the field list explicitly with `hmget` in the model\nserver.\n\nThe exception is debugging and feature-set discovery, where you\ngenuinely want the full hash. The demo's \"Inspect\" button uses\n`hgetall` for exactly this reason."
    },
    {
      "id": "inspect-the-store-directly-with-redis-cli",
      "title": "Inspect the store directly with redis-cli",
      "role": "content",
      "text": "When testing or troubleshooting, the cli tells you everything:\n\n[code example]\n\nA streaming field that returns `-2` from `HTTL` doesn't exist on the\nhash (either it was never written, or it expired); `-1` means the\nfield has no TTL set (and is therefore covered only by the key-level\n`EXPIRE`); any positive value is the remaining TTL in seconds."
    },
    {
      "id": "learn-more",
      "title": "Learn more",
      "role": "related",
      "text": "This example uses the following Redis commands:\n\n* [`HSET`](https://redis.io/docs/latest/commands/hset) to write a feature or a\n  whole feature row in one call.\n* [`HMGET`](https://redis.io/docs/latest/commands/hmget) to retrieve any subset\n  of features for one entity in one round trip.\n* [`HGETALL`](https://redis.io/docs/latest/commands/hgetall) for debugging and\n  feature-set discovery.\n* [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) and\n  [`HTTL`](https://redis.io/docs/latest/commands/httl) for per-field TTL on\n  streaming features (Redis 7.4+).\n* [`EXPIRE`](https://redis.io/docs/latest/commands/expire) and\n  [`TTL`](https://redis.io/docs/latest/commands/ttl) for the whole-entity TTL\n  aligned with the batch materialization cycle.\n* Pipelined `HMGET` across many entities for batch scoring with one\n  network round trip — see\n  [transactions and pipelining](https://redis.io/docs/latest/develop/clients/php/transpipe).\n\nSee the [Predis documentation](https://redis.io/docs/latest/develop/clients/php)\nfor the full client reference, and the\n[Hashes overview](https://redis.io/docs/latest/develop/data-types/hashes) for the\ndeeper conceptual model."
    }
  ],
  "examples": [
    {
      "id": "project-layout-ex0",
      "language": "text",
      "code": "feature-store/php/\n├── composer.json           — predis/predis ^3, PHP >= 8.1\n├── FeatureStore.php        — FeatureStore class\n├── StreamingWorker.php     — worker tick loop (used by the CLI process)\n├── BuildFeatures.php       — synthesize_users + helpers\n├── build_features.php      — CLI entry point for the materializer\n├── streaming_worker.php    — CLI entry point for the worker process\n├── demo_server.php         — php -S router (HTTP routes + worker spawn)\n└── demo_template.html      — HTML page, loaded by file_get_contents",
      "section_id": "project-layout"
    },
    {
      "id": "the-feature-store-helper-ex0",
      "language": "php",
      "code": "<?php\nrequire __DIR__ . '/vendor/autoload.php';\nrequire __DIR__ . '/FeatureStore.php';\n\nuse Predis\\Client;\n\n$redis = new Client('tcp://127.0.0.1:6379');\n$store = new FeatureStore(\n    $redis,\n    keyPrefix: 'fs:user:',\n    batchTtlSeconds: 24 * 60 * 60,    // whole-entity TTL aligned with the daily batch cycle\n    streamingTtlSeconds: 5 * 60,      // per-field TTL on each streaming feature\n);\n\n// Batch materialization: one HSET + EXPIRE per user, all pipelined.\n$store->bulkLoad([\n    'u0001' => [\n        'country_iso' => 'US', 'risk_segment' => 'low',\n        'tx_count_7d' => 14, 'avg_amount_30d' => 92.40,\n        'account_age_days' => 612, 'chargeback_count_180d' => 0,\n    ],\n], 24 * 60 * 60);\n\n// Streaming write: HSET + HEXPIRE on just the fields that changed.\n$store->updateStreaming('u0001', [\n    'last_login_ts' => (int)(microtime(true) * 1000),\n    'last_device_id' => 'ios-9f02',\n    'tx_count_5m' => 3,\n    'failed_logins_15m' => 0,\n    'session_country' => 'US',\n], 5 * 60);\n\n// Inference read: HMGET of whatever the model needs.\n$features = $store->getFeatures('u0001', [\n    'risk_segment', 'tx_count_7d', 'avg_amount_30d',\n    'tx_count_5m', 'failed_logins_15m',\n]);\n\n// Batch scoring: pipelined HMGET across many users.\n$batch = $store->batchGetFeatures(\n    ['u0001', 'u0002', 'u0003'],\n    ['risk_segment', 'tx_count_5m', 'failed_logins_15m'],\n);",
      "section_id": "the-feature-store-helper"
    },
    {
      "id": "data-model-ex0",
      "language": "text",
      "code": "fs:user:u0001                                   TTL = 86400 s (key-level)\n  country_iso=US                                <no field TTL>\n  risk_segment=low                              <no field TTL>\n  account_age_days=612                          <no field TTL>\n  tx_count_7d=14                                <no field TTL>\n  avg_amount_30d=92.40                          <no field TTL>\n  chargeback_count_180d=0                       <no field TTL>\n  last_login_ts=1716998413541                   TTL = 300 s (per field, HEXPIRE)\n  last_device_id=ios-9f02                       TTL = 300 s (per field, HEXPIRE)\n  tx_count_5m=3                                 TTL = 300 s (per field, HEXPIRE)\n  failed_logins_15m=0                           TTL = 300 s (per field, HEXPIRE)\n  session_country=US                            TTL = 300 s (per field, HEXPIRE)",
      "section_id": "data-model"
    },
    {
      "id": "bulk-loading-batch-features-ex0",
      "language": "php",
      "code": "public function bulkLoad(array $rows, ?int $ttlSeconds = null): int\n{\n    if (count($rows) === 0) return 0;\n    $ttl = $ttlSeconds ?? $this->batchTtlSeconds;\n    $this->redis->pipeline(function ($pipe) use ($rows, $ttl) {\n        foreach ($rows as $entityId => $fields) {\n            $key = $this->keyFor((string)$entityId);\n            // Predis 3's `hset` accepts variadic field/value pairs\n            // (key, f1, v1, f2, v2, ...) but not a single field=>value\n            // map argument the way Predis 2 did — flatten the encoded\n            // map into that shape.\n            $flat = [];\n            foreach ($fields as $name => $value) {\n                $flat[] = $name;\n                $flat[] = self::encodeValue($value);\n            }\n            $pipe->hset($key, ...$flat);\n            $pipe->expire($key, $ttl);\n        }\n    });\n    ...\n}",
      "section_id": "bulk-loading-batch-features"
    },
    {
      "id": "streaming-writes-with-per-field-ttl-ex0",
      "language": "php",
      "code": "public function updateStreaming(string $entityId, array $fields, ?int $ttlSeconds = null): void\n{\n    if (count($fields) === 0) return;\n    $ttl = $ttlSeconds ?? $this->streamingTtlSeconds;\n    $key = $this->keyFor($entityId);\n    $flat = [];\n    $names = [];\n    foreach ($fields as $name => $value) {\n        $names[] = $name;\n        $flat[] = $name;\n        $flat[] = self::encodeValue($value);\n    }\n\n    $results = $this->redis->pipeline(function ($pipe) use ($key, $flat, $names, $ttl) {\n        $pipe->hset($key, ...$flat);\n        $pipe->hexpire($key, $ttl, $names);\n    });\n    $codes = $results[1] ?? [];\n    foreach ($codes as $code) {\n        if ((int)$code !== 1) {\n            throw new RuntimeException(\n                \"HEXPIRE did not set every field TTL for {$key}: \" . json_encode($codes)\n            );\n        }\n    }\n    ...\n}",
      "section_id": "streaming-writes-with-per-field-ttl"
    },
    {
      "id": "inference-reads-with-hmget-ex0",
      "language": "php",
      "code": "public function getFeatures(string $entityId, ?array $fieldNames): array\n{\n    $key = $this->keyFor($entityId);\n    if ($fieldNames === null) {\n        return $this->redis->hgetall($key);\n    }\n    if (count($fieldNames) === 0) return [];\n    $values = $this->redis->hmget($key, $fieldNames);\n    $out = [];\n    foreach ($fieldNames as $i => $n) {\n        if ($values[$i] !== null) $out[$n] = (string)$values[$i];\n    }\n    return $out;\n}",
      "section_id": "inference-reads-with-hmget"
    },
    {
      "id": "batch-scoring-with-pipelined-hmget-ex0",
      "language": "php",
      "code": "public function batchGetFeatures(array $entityIds, array $fieldNames): array\n{\n    if (count($entityIds) === 0 || count($fieldNames) === 0) return [];\n    $rows = $this->redis->pipeline(function ($pipe) use ($entityIds, $fieldNames) {\n        foreach ($entityIds as $id) {\n            $pipe->hmget($this->keyFor($id), $fieldNames);\n        }\n    });\n    ...\n}",
      "section_id": "batch-scoring-with-pipelined-hmget"
    },
    {
      "id": "the-streaming-worker-ex0",
      "language": "php",
      "code": "public function run(): void\n{\n    $this->redis->set('fs:control:worker_pid', (string)getmypid());\n    $this->redis->set('fs:control:running', '1');\n    // SIGTERM / SIGINT trap so the demo server's shutdown path\n    // can cleanly kill us via `posix_kill($pid, SIGTERM)`.\n    ...\n    try {\n        while (true) {\n            if ($this->redis->get('fs:control:stop') === '1') break;\n            $this->microsleep($this->tickSeconds);\n            if ($this->redis->get('fs:control:stop') === '1') break;\n            // Set tick_in_flight *before* the pause check so a\n            // concurrent pause + wait_for_idle (reset path) can\n            // never observe tick_in_flight=0 in the window between\n            // the pause check and the actual tick call.\n            $this->redis->set('fs:control:tick_in_flight', '1');\n            try {\n                if ($this->redis->get('fs:control:paused') !== '1') {\n                    $this->doTick();\n                }\n            } catch (\\Throwable $e) {\n                fwrite(STDERR, \"[streaming-worker] tick failed: \" . $e->getMessage() . \"\\n\");\n            } finally {\n                $this->redis->set('fs:control:tick_in_flight', '0');\n            }\n        }\n    } finally {\n        // Clear running, tick_in_flight, stop no matter how the loop\n        // exits so a later restart can spin a fresh worker with a\n        // clean slate.\n        $this->redis->del(...[\n            'fs:control:running', 'fs:control:tick_in_flight',\n            'fs:control:worker_pid', 'fs:control:stop',\n        ]);\n    }\n}",
      "section_id": "the-streaming-worker"
    },
    {
      "id": "the-streaming-worker-ex1",
      "language": "bash",
      "code": "redis-cli SET fs:control:stop 1",
      "section_id": "the-streaming-worker"
    },
    {
      "id": "the-batch-builder-ex0",
      "language": "bash",
      "code": "php build_features.php --count 500 --ttl-seconds 3600",
      "section_id": "the-batch-builder"
    },
    {
      "id": "get-the-source-files-ex0",
      "language": "bash",
      "code": "git clone https://github.com/redis/docs.git\ncd docs/content/develop/use-cases/feature-store/php\ncomposer install",
      "section_id": "get-the-source-files"
    },
    {
      "id": "start-the-demo-server-ex0",
      "language": "bash",
      "code": "composer start\n# or, equivalently:\nphp -S 127.0.0.1:8094 demo_server.php",
      "section_id": "start-the-demo-server"
    },
    {
      "id": "start-the-demo-server-ex1",
      "language": "text",
      "code": "[Mon Jun  1 ...] PHP 8.4 Development Server (http://127.0.0.1:8094) started\n[Mon Jun  1 ...] 127.0.0.1:... Accepted",
      "section_id": "start-the-demo-server"
    },
    {
      "id": "inspect-the-store-directly-with-redis-cli-ex0",
      "language": "bash",
      "code": "# How many users currently in the store\nredis-cli --scan --pattern 'fs:user:*' | wc -l\n\n# One user's full hash and key-level TTL\nredis-cli HGETALL fs:user:u0001\nredis-cli TTL    fs:user:u0001\n\n# Per-field TTL on the streaming fields\nredis-cli HTTL fs:user:u0001 FIELDS 5 \\\n  last_login_ts last_device_id tx_count_5m failed_logins_15m session_country\n\n# Sample HMGET as the model would issue it\nredis-cli HMGET fs:user:u0001 risk_segment tx_count_7d avg_amount_30d tx_count_5m\n\n# Inspect the worker's control state\nredis-cli MGET fs:control:worker_pid fs:control:paused \\\n  fs:control:tick_in_flight fs:control:tick_count",
      "section_id": "inspect-the-store-directly-with-redis-cli"
    }
  ]
}
