{"id":50962800,"url":"https://github.com/iliaal/phpser","last_synced_at":"2026-06-18T16:30:37.796Z","repository":{"id":359183500,"uuid":"1244910714","full_name":"iliaal/phpser","owner":"iliaal","description":"Fast binary serializer for PHP cache workloads. Decoder-optimized, beats igbinary on packed numerics, deep-nested structures, and same-class DTO batches.","archived":false,"fork":false,"pushed_at":"2026-05-29T04:25:49.000Z","size":747,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-05-29T06:08:05.972Z","etag":null,"topics":["cache","performance","php","php-extension","pie","serialization"],"latest_commit_sha":null,"homepage":"https://github.com/iliaal/phpser","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"bsd-3-clause","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/iliaal.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-20T18:10:15.000Z","updated_at":"2026-05-29T04:25:54.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/iliaal/phpser","commit_stats":null,"previous_names":["iliaal/phpser"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/iliaal/phpser","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iliaal%2Fphpser","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iliaal%2Fphpser/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iliaal%2Fphpser/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iliaal%2Fphpser/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/iliaal","download_url":"https://codeload.github.com/iliaal/phpser/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/iliaal%2Fphpser/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34499403,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-18T02:00:06.871Z","response_time":128,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cache","performance","php","php-extension","pie","serialization"],"created_at":"2026-06-18T16:30:37.046Z","updated_at":"2026-06-18T16:30:37.790Z","avatar_url":"https://github.com/iliaal.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# phpser\n\n[![Tests](https://github.com/iliaal/phpser/actions/workflows/tests.yml/badge.svg)](https://github.com/iliaal/phpser/actions/workflows/tests.yml)\n[![Version](https://img.shields.io/github/v/release/iliaal/phpser)](https://github.com/iliaal/phpser/releases)\n[![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD--3--Clause-green.svg)](https://opensource.org/licenses/BSD-3-Clause)\n[![Follow @iliaa](https://img.shields.io/badge/Follow-@iliaa-000000?style=flat\u0026logo=x\u0026logoColor=white)](https://x.com/intent/follow?screen_name=iliaa)\n\n![phpser: high-performance PHP serializer, decoder-optimized for cache workloads](images/phpser-hero.jpg)\n\nA PHP serialization extension in C, targeting read-heavy cache workloads\nwhere decode time matters more than encode time or payload size.\n\n## Why phpser?\n\nPHP cache workloads pay decode cost on every read. Encode happens once per write. The default `igbinary` was the right answer for over a decade, but lags on three shapes that show up everywhere: packed numeric arrays, deep-nested structures, and same-class DTO batches (Laravel queue payloads, cached models).\n\nphpser is decoder-optimized. Pointer-equality dict intern, refcount-reuse of zend_strings, pre-sized hash tables with direct `arPacked` writes, tagged scalar runs, an O(1) pointer-hash intern cache. On the shapes above, it cuts size by 60-65% and decode time by 70-77% vs igbinary. On general-purpose rowsets it sits within 1% of igbinary's size and encodes 18-53% faster.\n\nphpser is now also faster to **encode** than igbinary on every shape in the suite (−14% to −70%), so it's no longer just a read-path win. The remaining non-wins are small and on the de-prioritized axes: rowset size runs ~1-4% over igbinary, and `rowset_1000` decode ~4% slower (the front-loaded dictionary trades streamability for decode speed everywhere else). The bench table below has the full shape-by-shape breakdown.\n\n📖 **The design writeup:** [phpser: a fast, secure binary serializer for PHP cache workloads](https://ilia.ws/blog/phpser-a-fast-secure-binary-serializer-for-php-cache-workloads) — what the decoder does differently and why decode time is the metric to optimize. The [interactive benchmark page](https://iliaal.github.io/phpser/) compares phpser against igbinary, native `serialize()`, and msgpack across every cache shape.\n\n## Install\n\n```bash\n# PIE (PHP Foundation's extension installer; uses the composer.json\n# at the repo root with type: \"php-ext\")\npie install iliaal/phpser\n```\n\nOn a minimal PHP image (e.g. `php:8.x-cli` from Docker Hub), PIE needs a\nfew build tools installed first:\n\n```bash\n# Debian/Ubuntu\nsudo apt install -y git bison libtool-bin unzip\n\n# macOS\nbrew install bison libtool\n```\n\n`unzip` is load-bearing on Debian: composer shells out to `/usr/bin/unzip`\nwhen extracting PIE's prebuilt-binary zip. If `unzip` is missing, composer\nsilently falls back to PHP's ZipArchive which lays the `.so` out at a\npath PIE doesn't check, and install fails with `ExtensionBinaryNotFound`\neven though the zip downloaded fine.\n\n### From source\n\n```bash\ngit clone https://github.com/iliaal/phpser.git\ncd phpser\nphpize \u0026\u0026 ./configure --enable-phpser\nmake -j$(nproc)\nsudo make install\necho 'extension=phpser.so' | sudo tee /etc/php/conf.d/phpser.ini\n```\n\n### Pre-built binaries\n\nPre-built `.dll`s for Windows (PHP 8.2-8.5, TS/NTS, x64) and `.so`s for\nLinux glibc (x86_64, arm64) and macOS arm64 (PHP 8.4-8.5) are attached\nto each [GitHub release](https://github.com/iliaal/phpser/releases). PIE\nfetches the matching binary automatically; falls back to source-build\nwhen no asset matches.\n\n## Usage\n\nBasic round-trip. The encoded payload is opaque bytes; treat it as a\nbinary blob in storage (no JSON-safety, no UTF-8 guarantees):\n\n```php\n$payload = phpser_serialize(['id' =\u003e 42, 'name' =\u003e 'row', 'tags' =\u003e ['a','b']]);\n$value   = phpser_unserialize($payload);\n// $value === ['id' =\u003e 42, 'name' =\u003e 'row', 'tags' =\u003e ['a','b']]\n```\n\nHMAC-signed mode for untrusted storage (memcached, redis, files,\ncookies). The signed entry points wrap the payload in a constant-time\nHMAC-SHA256 frame; tampered or foreign-keyed input is rejected before\nany decoding work runs:\n\n```php\n$key = random_bytes(32);  // store this key in your app config; an empty key is rejected\n\n$payload = phpser_serialize_signed($cacheValue, $key);\n// ... later, possibly across a process boundary ...\n$value = phpser_unserialize_signed($payload, $key);\n// throws an Exception if the payload was tampered or signed with a different key\n```\n\n`allowed_classes` option on both unserialize entry points. Same shape as\nPHP's native `unserialize($payload, ['allowed_classes' =\u003e ...])`:\n\n```php\n// Reject all classes (decode them as __PHP_Incomplete_Class)\n$value = phpser_unserialize($payload, ['allowed_classes' =\u003e false]);\n\n// Allowlist specific classes; everything else becomes __PHP_Incomplete_Class\n$value = phpser_unserialize($payload, ['allowed_classes' =\u003e [Foo::class, Bar::class]]);\n\n// Allow all (default)\n$value = phpser_unserialize($payload, ['allowed_classes' =\u003e true]);\n$value = phpser_unserialize($payload);  // same as above\n```\n\nWhen decoding attacker-controlled bytes, use one of the two restricted\nmodes or the signed entry point. See `SECURITY.md` for the full threat\nmodel.\n\n## ✨ Features\n\n- **Signed payloads for integrity.** `phpser_serialize_signed($value, $key)` wraps the payload in an HMAC-SHA256 frame; `phpser_unserialize_signed($payload, $key)` verifies in constant time and rejects tampered or foreign-keyed input *before* any decoding work runs. Use this whenever the storage layer crosses a trust boundary: memcached, redis, files, cookies, anywhere an attacker who can write to the store could otherwise feed a crafted payload to your decoder. An empty key is rejected on both sides — a keyless HMAC is forgeable, so callers must supply real key material.\n- **Safe handling of untrusted input.** `allowed_classes` option on both unserialize entry points, matching PHP's native `unserialize($payload, ['allowed_classes' =\u003e ...])` shape: pass `false` to reject all classes, an array to allowlist specific ones, or `true` for the default. Disallowed classes decode as `__PHP_Incomplete_Class` with the original name preserved, never instantiated. Recursion depth is capped at 512 on both encode and decode (encode throws, decode returns `null`), and assoc decode uses `zend_hash_update` so duplicate-key payloads collapse to last-write-wins rather than phantom buckets.\n- **PHP 8.2+ (8.3, 8.4, 8.5, master).** BSD 3-Clause.\n\n## Bench (opt PHP 8.4.22-dev NTS release, 1000 iters, median of 9 runs)\n\n| Shape | Size: ig → ps | Encode: ig → ps | Decode: ig → ps |\n|---|---|---|---|\n| rowset_100 | 4570 → **4771** (**+4.4%**) | 10.2k → **7.5k** ns (**-27%**) | 11.2k → 11.1k ns (~parity) |\n| rowset_1000 | 47K → 48K (**+1.1%**) | 170k → **77k** ns (**-55%**) | 108k → 110k ns (+2%) |\n| packed_1k | 5495 → **1941** (**-65%**) | 4.5k → **1.2k** ns (**-73%**) | 7.4k → **1.8k** ns (**-76%**) |\n| packed_10k | 60K → **22K** (**-63%**) | 46k → **12k** ns (**-73%**) | 76k → **19k** ns (**-75%**) |\n| deep_50 | 419 → 424 (parity) | 1.4k → **0.73k** ns (**-48%**) | 1.9k → **1.6k** ns (**-16%**) |\n| dto_100 | 7083 → **6362** (**-10%**) | 17k → **13k** ns (**-24%**) | 29k → **18k** ns (**-40%**) |\n| dto_1000 | 73K → **65K** (**-12%**) | 204k → **172k** ns (**-16%**) | 297k → **175k** ns (**-41%**) |\n| dto_mixed | 22K → **18K** (**-17%**) | 63k → **43k** ns (**-31%**) | 123k → **61k** ns (**-51%**) |\n\nphpser is faster to encode than igbinary on **every** shape in the\nsuite (−16% to −73%) while staying decoder-first. Packed numerics:\n~65% smaller, ~73% faster encode, ~75% faster decode. Deep-nested:\n~48% faster encode at parity size. **Rowsets encode 27-55% faster**,\nsize within ~1%, decode at parity on x86 and 8-9% faster on arm64.\nDTO workloads (Laravel-queue-style payloads, single-class arrays):\n**10-17% smaller, 40-51% faster decode, 16-31% faster encode** vs\nigbinary — dict dedup on prop names, the class-entry lookup cache that\namortizes `zend_lookup_class_ex` across same-typed batches, an O(1)\npointer-hash intern cache that keeps the per-value dedup lookup off\nthe critical path, dict strings resolved against the engine's\ninterned-string table on decode, and declared properties installed\nstraight into property slots instead of materializing each object's\nproperties HashTable.\n\nThe remaining non-win is small and on the de-prioritized axis: rowset\nsize is ~1-4% over igbinary — the front-loaded dictionary is read once\nat the head and referenced by index, which is exactly what makes the\ndecodes fast (not streamable; you can't have both).\n\nCross-validated on arm64 (aarch64, PHP 8.4.21 NTS, idle, median of 9):\ndecode is faster on every shape including the rowsets (rowset −8/−9%,\ndto −40/−43/−48%, packed −78/−79%, deep −16%); encode −9% to −75%.\n\nFor the full four-way picture — phpser vs igbinary vs native `serialize()`\nvs msgpack, with size/encode/decode side by side on every shape — see the\n**[interactive benchmark page](https://iliaal.github.io/phpser/)** (arm64,\nmedian of 9). Regenerate it with `php ... bench.php --html \u003e docs/index.html`.\nThe short version: phpser decodes faster than all three on every shape,\nand the object (`dto_*`) decode that msgpack is slowest at is exactly\nthe Laravel-queue workload phpser targets.\n\n## Design highlights\n\nThe core ideas that drive the perf wins above:\n\n- **Pointer-equality dict intern.** Encoding hits a `*zend_string == *zend_string`\n  check first; only on miss do we hash the bytes. Cuts intern cost to\n  near-zero for rowset-shaped data where PHP literals share interned\n  zend_strings.\n- **Front-loaded string dictionary.** Same shape as igbinary's\n  `compact_strings`, except we emit the table once at the head and\n  reference by varint index from values. Trade-off: not streamable.\n- **Refcount-reuse of zend_strings on decode.** Per-decode cache parallel\n  to the dict. First reference allocates, subsequent ones `addref`.\n- **HT_IS_PACKED detection via flag, not iteration.** Avoid scanning the\n  buckets just to determine layout.\n- **`arPacked` stride awareness.** PHP 8+'s packed-array layout stores\n  zvals directly, not Buckets. Stride is 16, not 32.\n- **Sparse-packed fallback.** Arrays with holes (post-`unset`) preserve\n  original int keys via Assoc rather than silently re-indexing.\n\n## Where phpser diverges from igbinary\n\nigbinary is the closest reference point. The areas where there's still\nmeasurable perf to take, and that this project targets, are:\n\n1. **Pre-sized HT + direct `arPacked` writes on decode.** When the wire\n   format declares `PACKED_LEN N`, allocate the HT once via\n   `zend_new_array(N)` and write directly into `arPacked` with `ZVAL_*`\n   macros. Skips N `zend_hash_next_index_insert` calls, including their\n   hash computation, growth checks, and capacity tuning. **Shipped.**\n2. **Tagged scalar runs.** `[1, 2, 3, ...]` (1000 longs) emits as a\n   single `PACKED_LONGS` header + N zigzag varints, not 1000 `(tag,\n   varint)` pairs. Decode is one tight loop with no per-element tag\n   dispatch. **Shipped.**\n3. **O(1) pointer-hash intern.** Open-addressed `zend_string* → slot`\n   hash, grown without eviction. Hit rate near 100% on rowset shapes (PHP\n   interns literals; the same `\"id\"` zend_string pointer flows through\n   every row), and unique value strings (names, emails) hit a single-probe\n   miss instead of a linear scan — the change that put encode ahead of\n   igbinary on every shape. Skips the byte-hash entirely on hits.\n   **Shipped.**\n4. **Eager dict materialization with warm hashes.** All dict slots are\n   resolved up front during header parse, against the engine's\n   interned-string table first — property names, class names, and hot\n   literals come back as the engine's own interned strings (no\n   allocation, no refcount traffic, pointer-equality hash lookups) —\n   with a regular allocation as the fallback. Hashes are set on both\n   paths; `zend_hash_add_new` reuses the cached hash. **Shipped.**\n5. **Provenance-gated `add_new` on assoc decode.** The default\n   (unsigned) path uses `zend_hash_update`: it's the security boundary, and\n   adversarial payloads with duplicate keys must collapse to last-write-wins\n   rather than produce phantom buckets (`count($arr) !=\n   count(array_unique(array_keys($arr)))`). The HMAC-authenticated\n   `phpser_unserialize_signed` path provably came from our own encoder\n   (unique-keyed HashTables, no duplicates), so it uses `zend_hash_*_add_new`\n   and skips the per-key existence check. **Shipped.**\n6. **Inline-short-string tag with upgrade-on-second-encounter.**\n   `TAG_STR_INLINE` (0x0c) and `KEY_STR_INLINE` (0x02) are emitted on a\n   string's first occurrence; the next occurrence triggers an in-place\n   upgrade to a dict entry, and all subsequent ones emit `TAG_STR_DICT`.\n   Singletons (e.g. `row_X` values in a rowset) never hit the upgrade\n   branch. They cost nothing in the dict header. The intern cache\n   doubles as the \"seen once?\" signal: high bit of `idx` distinguishes\n   `INLINE_EMITTED` from `DICT_IDX`. No pre-pass; single walk of the\n   zval tree as before.\n\n   A count-then-emit variant was tried first: pre-walk the zval tree\n   to tag occurrences, then emit inline for singletons and dict for\n   repeats. The pre-pass cost ~200 ns per string and ate the\n   per-singleton savings, so the single-walk upgrade-on-second-encounter\n   version above is what ships. `rowset_1000` encode landed at 25%\n   faster than igbinary (up from 8% in the pre-upgrade implementation),\n   with payload size dropping from +5% to +2.7%.\n7. **Skip refcount machinery during build.** All zvals built during decode\n   are fresh and unshared until handed back to PHP. Internal writes can\n   skip `Z_TRY_ADDREF` guards.\n\n## Local dev build\n\nThe hand-rolled `Makefile` builds against an in-tree `~/php-src-8.4-opt`\ncheckout without `phpize`/`autoconf`. Useful for hacking on the extension\nwhile also hacking on PHP itself:\n\n```sh\nmake -j$(nproc)           # builds modules/phpser.so\nmake test                 # runs tests/*.phpt via run-tests.php\n```\n\nOverride `PHP_SRC=` to target a different in-tree PHP checkout. Load\nalongside igbinary for the A/B bench:\n\n```sh\n~/php-src-8.4-opt/sapi/cli/php \\\n  -d extension=$HOME/igbinary/modules/igbinary.so \\\n  -d extension=$(pwd)/modules/phpser.so \\\n  bench.php\n```\n\nThe `config.m4` auto-detects the session extension and registers phpser\nas a `session.serialize_handler` when available.\n\n## Limitations / known gaps\n\n- **Recursion depth is capped at 512** on both encode and decode. On decode,\n  anything deeper than 512 nested containers / refs is rejected (returns\n  `null`) to bound stack consumption against adversarial wire payloads. On\n  encode, input deeper than 512 throws an `Exception` rather than silently\n  shipping a truncated payload. Object cycles are\n  preserved correctly via the id-table machinery and don't count against\n  this cap for shared-graph cases; the cap only fires on genuinely deep\n  trees. Cache workloads typically nest 5-10 deep, so the cap is many\n  orders of magnitude past any legitimate payload.\n- **Closures and resources encode as `NULL`.** Same shape as PHP's own\n  `serialize()`; these types are inherently non-serializable.\n- **Unknown classes at decode fall back to `stdClass`** rather than PHP's\n  `__PHP_Incomplete_Class`. This is deliberate for the typical cache\n  workload; `allowed_classes =\u003e [...]` produces `__PHP_Incomplete_Class`\n  with the original name preserved for disallowed classes, matching PHP.\n- **`session.serialize_handler=phpser` is shipped** (compiled in when\n  `phpize` detects the session extension; gated on `HAVE_PHP_SESSION` so\n  the extension still loads on session-less PHP builds). `phpredis`\n  integration is not yet wired; call `phpser_serialize`/`unserialize`\n  directly when using the extension as a phpredis serializer.\n\n## Wire format (V1)\n\n```\n[u8 version=0x01]\n[varint ndict]\n  per entry: [varint len] [bytes]\n[value]\n\nvalue tags:\n  0x00 NULL\n  0x01 FALSE\n  0x02 TRUE\n  0x03 LONG            varint (zigzag-encoded)\n  0x04 DOUBLE          8 bytes (LE)\n  0x05 STR_DICT        varint dict_idx\n  0x06 ASSOC           varint(len), N×(key, val)\n  0x07 PACKED_MIXED    varint(len), N×val\n  0x08 PACKED_LONGS    varint(len), N×zigzag-varint\n  0x09 PACKED_DOUBLES  varint(len), N×8-byte LE\n  0x0a OBJECT          varint(class_idx), varint(nprops), N×(key_idx, val)\n  0x0b PACKED_STRINGS  varint(len), N×varint(dict_idx)  // typed string run\n  0x0c STR_INLINE      varint(len), bytes  // single-use string, skips dict\n  0x0d ENUM            varint(class_idx), varint(case_name_idx)\n  0x0e OBJECT_MAGIC    varint(class_idx), value  // class with __serialize;\n                       // value is the array __serialize returned\n  0x0f OBJECT_LEGACY   varint(class_idx), varint(len), bytes  // class with\n                       // ce-\u003eserialize / ce-\u003eunserialize (Serializable etc.)\n  0x10 REF             varint(id)  // back-ref to a previously-emitted container\n  0x11 NEW_REF         value  // claims the next id for an IS_REFERENCE wrap\n\nkey tags:\n  0x00 LONG            varint(zigzag)\n  0x01 STR             varint(dict_idx)\n  0x02 STR_INLINE      varint(len), bytes\n```\n\nVarints are LEB128 (unsigned); signed values use zigzag encoding. Tags\n0x10/0x11 plus 0x0a/0x0d/0x0e/0x0f each implicitly claim the next id in\nencounter order, so the decoder reconstructs back-refs by counting\ncontainer tags as it parses.\n\n## 🔗 PHP Performance Toolkit\n\nCompanion native PHP extensions:\n\n- [php_excel](https://github.com/iliaal/php_excel): native XLS/XLSX read/write via LibXL\n- [mdparser](https://github.com/iliaal/mdparser): native CommonMark + GitHub Flavored Markdown parser\n- [php_clickhouse](https://github.com/iliaal/php_clickhouse): native ClickHouse client over the binary protocol\n- [fastchart](https://github.com/iliaal/fastchart): 26 chart types in one PHP extension\n- [fastjson](https://github.com/iliaal/fastjson): drop-in faster `ext/json`, backed by yyjson\n- [statgrab](https://github.com/iliaal/statgrab): system statistics wrapper around libstatgrab\n\n---\n\n[Follow on X](https://x.com/iliaa) • [Read the writeup](https://ilia.ws/blog/phpser-a-fast-secure-binary-serializer-for-php-cache-workloads) • If this cut your cache decode CPU, ⭐ star it!\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Filiaal%2Fphpser","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Filiaal%2Fphpser","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Filiaal%2Fphpser/lists"}