PHPackages                             iliaal/phpser - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Caching](/categories/caching)
4. /
5. iliaal/phpser

ActivePhp-ext[Caching](/categories/caching)

iliaal/phpser
=============

Fast binary serializer for PHP cache workloads. Decoder-optimized, beats igbinary on packed numerics, deep-nested structures, and same-class DTO batches.

0.1.0(2w ago)130BSD-3-ClausePHPPHP &gt;=8.3CI passing

Since May 20Pushed 4d ago1 watchersCompare

[ Source](https://github.com/iliaal/phpser)[ Packagist](https://packagist.org/packages/iliaal/phpser)[ Docs](https://github.com/iliaal/phpser)[ RSS](/packages/iliaal-phpser/feed)WikiDiscussions master Synced 1w ago

READMEChangelog (3)DependenciesVersions (4)Used By (0)

phpser
======

[](#phpser)

[![Tests](https://github.com/iliaal/phpser/actions/workflows/tests.yml/badge.svg)](https://github.com/iliaal/phpser/actions/workflows/tests.yml)[![Version](https://camo.githubusercontent.com/4a4ec5727e8c7e8a965cee7c7932a4a6a2d2b0dd2b25ab35c71c8d923b43804e/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f762f72656c656173652f696c6961616c2f706870736572)](https://github.com/iliaal/phpser/releases)[![License: BSD-3-Clause](https://camo.githubusercontent.com/5b18cec5d64abf4a1fb017b12bcf376f4f1aa4c91aa28a9669664f8b2666eb62/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4253442d2d332d2d436c617573652d677265656e2e737667)](https://opensource.org/licenses/BSD-3-Clause)[![Follow @iliaa](https://camo.githubusercontent.com/a54521c97521f05fbadec4bd9bcba96ff1eeaffe756a6d7338b47a628cdeb39b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f466f6c6c6f772d40696c6961612d3030303030303f7374796c653d666c6174266c6f676f3d78266c6f676f436f6c6f723d7768697465)](https://x.com/intent/follow?screen_name=iliaa)

[![phpser: high-performance PHP serializer, decoder-optimized for cache workloads](images/phpser-hero.jpg)](images/phpser-hero.jpg)

A PHP serialization extension in C, targeting read-heavy cache workloads where decode time matters more than encode time or payload size.

Why phpser?
-----------

[](#why-phpser)

PHP 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).

phpser 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.

phpser 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.

📖 **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.

Install
-------

[](#install)

```
# PIE (PHP Foundation's extension installer; uses the composer.json
# at the repo root with type: "php-ext")
pie install iliaal/phpser
```

On a minimal PHP image (e.g. `php:8.x-cli` from Docker Hub), PIE needs a few build tools installed first:

```
# Debian/Ubuntu
sudo apt install -y git bison libtool-bin unzip

# macOS
brew install bison libtool
```

`unzip` is load-bearing on Debian: composer shells out to `/usr/bin/unzip`when extracting PIE's prebuilt-binary zip. If `unzip` is missing, composer silently falls back to PHP's ZipArchive which lays the `.so` out at a path PIE doesn't check, and install fails with `ExtensionBinaryNotFound`even though the zip downloaded fine.

### From source

[](#from-source)

```
git clone https://github.com/iliaal/phpser.git
cd phpser
phpize && ./configure --enable-phpser
make -j$(nproc)
sudo make install
echo 'extension=phpser.so' | sudo tee /etc/php/conf.d/phpser.ini
```

### Pre-built binaries

[](#pre-built-binaries)

Pre-built `.dll`s for Windows (PHP 8.2-8.5, TS/NTS, x64) and `.so`s for Linux glibc (x86\_64, arm64) and macOS arm64 (PHP 8.4-8.5) are attached to each [GitHub release](https://github.com/iliaal/phpser/releases). PIE fetches the matching binary automatically; falls back to source-build when no asset matches.

Usage
-----

[](#usage)

Basic round-trip. The encoded payload is opaque bytes; treat it as a binary blob in storage (no JSON-safety, no UTF-8 guarantees):

```
$payload = phpser_serialize(['id' => 42, 'name' => 'row', 'tags' => ['a','b']]);
$value   = phpser_unserialize($payload);
// $value === ['id' => 42, 'name' => 'row', 'tags' => ['a','b']]
```

HMAC-signed mode for untrusted storage (memcached, redis, files, cookies). The signed entry points wrap the payload in a constant-time HMAC-SHA256 frame; tampered or foreign-keyed input is rejected before any decoding work runs:

```
$key = random_bytes(32);  // store this key in your app config; an empty key is rejected

$payload = phpser_serialize_signed($cacheValue, $key);
// ... later, possibly across a process boundary ...
$value = phpser_unserialize_signed($payload, $key);
// throws an Exception if the payload was tampered or signed with a different key
```

`allowed_classes` option on both unserialize entry points. Same shape as PHP's native `unserialize($payload, ['allowed_classes' => ...])`:

```
// Reject all classes (decode them as __PHP_Incomplete_Class)
$value = phpser_unserialize($payload, ['allowed_classes' => false]);

// Allowlist specific classes; everything else becomes __PHP_Incomplete_Class
$value = phpser_unserialize($payload, ['allowed_classes' => [Foo::class, Bar::class]]);

// Allow all (default)
$value = phpser_unserialize($payload, ['allowed_classes' => true]);
$value = phpser_unserialize($payload);  // same as above
```

When decoding attacker-controlled bytes, use one of the two restricted modes or the signed entry point. See `SECURITY.md` for the full threat model.

✨ Features
----------

[](#-features)

- **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.
- **Safe handling of untrusted input.** `allowed_classes` option on both unserialize entry points, matching PHP's native `unserialize($payload, ['allowed_classes' => ...])` 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.
- **PHP 8.2+ (8.3, 8.4, 8.5, master).** BSD 3-Clause.

Bench (opt PHP 8.4.22-dev NTS release, 1000 iters, median of 9 runs)
--------------------------------------------------------------------

[](#bench-opt-php-8422-dev-nts-release-1000-iters-median-of-9-runs)

ShapeSize: ig → psEncode: ig → psDecode: ig → psrowset\_1004570 → **4771** (**+4.4%**)9.6k → **7.9k** ns (**-18%**)11k → 11k ns (~parity)rowset\_100047K → 48K (**+1.1%**)153k → **72k** ns (**-53%**)104k → 108k ns (+4%)packed\_1k5495 → **1941** (**-65%**)4.4k → **1.5k** ns (**-67%**)7.0k → **1.8k** ns (**-75%**)packed\_10k60K → **22K** (**-63%**)44k → **13k** ns (**-70%**)73k → **19k** ns (**-74%**)deep\_50419 → 424 (parity)1.3k → **0.63k** ns (**-52%**)1.8k → **1.6k** ns (**-11%**)dto\_1007083 → **6362** (**-10%**)16k → **13k** ns (**-14%**)27k → **23k** ns (**-15%**)dto\_100073K → **65K** (**-12%**)186k → **160k** ns (**-14%**)272k → **227k** ns (**-16%**)dto\_mixed22K → **18K** (**-17%**)59k → **40k** ns (**-32%**)111k → **79k** ns (**-29%**)phpser is faster to encode than igbinary on **every** shape in the suite (−14% to −70%) while staying decoder-first. Packed numerics: ~65% smaller, ~70% faster encode, ~75% faster decode. Deep-nested: ~52% faster encode at parity size. **Rowsets encode 18-53% faster**, size within ~1%; rowset decode pays a small (~4%) tax for the front-loaded dict-header walk. DTO workloads (Laravel-queue-style payloads, single-class arrays): **10-17% smaller, 15-29% faster decode, 14-32% faster encode** vs igbinary — dict dedup on prop names, the class-entry lookup cache that amortizes `zend_lookup_class_ex` across same-typed batches, and an O(1) pointer-hash intern cache that keeps the per-value dedup lookup off the critical path.

The remaining non-wins are small and on the de-prioritized axes: rowset size is ~1-4% over igbinary, and `rowset_1000` decode runs ~4% slower — the front-loaded dictionary is read once at the head and referenced by index, which is exactly what makes the other decodes fast (not streamable; you can't have both).

Cross-validated on arm64 (aarch64, PHP 8.4.21 NTS, idle, median of 9): same direction on every shape — encode −4% to −66%, decode wins on all but `rowset_1000` (+4%). The encode margins on object shapes are narrower than x86 (dto\_100 −4%, dto\_mixed −24%) but still ahead.

For the full four-way picture — phpser vs igbinary vs native `serialize()`vs msgpack, with size/encode/decode side by side on every shape — see the **[interactive benchmark page](https://iliaal.github.io/phpser/)** (arm64, median of 9). Regenerate it with `php ... bench.php --html > docs/index.html`. The short version: phpser decodes faster than all three on every shape but the rowsets, and the object (`dto_*`) decode that msgpack is slowest at is exactly the Laravel-queue workload phpser targets.

Design highlights
-----------------

[](#design-highlights)

The core ideas that drive the perf wins above:

- **Pointer-equality dict intern.** Encoding hits a `*zend_string == *zend_string`check first; only on miss do we hash the bytes. Cuts intern cost to near-zero for rowset-shaped data where PHP literals share interned zend\_strings.
- **Front-loaded string dictionary.** Same shape as igbinary's `compact_strings`, except we emit the table once at the head and reference by varint index from values. Trade-off: not streamable.
- **Refcount-reuse of zend\_strings on decode.** Per-decode cache parallel to the dict. First reference allocates, subsequent ones `addref`.
- **HT\_IS\_PACKED detection via flag, not iteration.** Avoid scanning the buckets just to determine layout.
- **`arPacked` stride awareness.** PHP 8+'s packed-array layout stores zvals directly, not Buckets. Stride is 16, not 32.
- **Sparse-packed fallback.** Arrays with holes (post-`unset`) preserve original int keys via Assoc rather than silently re-indexing.

Where phpser diverges from igbinary
-----------------------------------

[](#where-phpser-diverges-from-igbinary)

igbinary is the closest reference point. The areas where there's still measurable perf to take, and that this project targets, are:

1. **Pre-sized HT + direct `arPacked` writes on decode.** When the wire format declares `PACKED_LEN N`, allocate the HT once via `zend_new_array(N)` and write directly into `arPacked` with `ZVAL_*`macros. Skips N `zend_hash_next_index_insert` calls, including their hash computation, growth checks, and capacity tuning. **Shipped.**
2. **Tagged scalar runs.** `[1, 2, 3, ...]` (1000 longs) emits as a single `PACKED_LONGS` header + N zigzag varints, not 1000 `(tag, varint)` pairs. Decode is one tight loop with no per-element tag dispatch. **Shipped.**
3. **O(1) pointer-hash intern.** Open-addressed `zend_string* → slot`hash, grown without eviction. Hit rate near 100% on rowset shapes (PHP interns literals; the same `"id"` zend\_string pointer flows through every row), and unique value strings (names, emails) hit a single-probe miss instead of a linear scan — the change that put encode ahead of igbinary on every shape. Skips the byte-hash entirely on hits. **Shipped.**
4. **Eager dict materialization with warm hashes.** All dict zend\_strings allocated up front during header parse and their hashes pre-computed. `zend_hash_add_new` reuses the cached hash. **Shipped.**
5. **Provenance-gated `add_new` on assoc decode.** The default (unsigned) path uses `zend_hash_update`: it's the security boundary, and adversarial payloads with duplicate keys must collapse to last-write-wins rather than produce phantom buckets (`count($arr) != count(array_unique(array_keys($arr)))`). The HMAC-authenticated `phpser_unserialize_signed` path provably came from our own encoder (unique-keyed HashTables, no duplicates), so it uses `zend_hash_*_add_new`and skips the per-key existence check. **Shipped.**
6. **Inline-short-string tag with upgrade-on-second-encounter.**`TAG_STR_INLINE` (0x0c) and `KEY_STR_INLINE` (0x02) are emitted on a string's first occurrence; the next occurrence triggers an in-place upgrade to a dict entry, and all subsequent ones emit `TAG_STR_DICT`. Singletons (e.g. `row_X` values in a rowset) never hit the upgrade branch. They cost nothing in the dict header. The intern cache doubles as the "seen once?" signal: high bit of `idx` distinguishes `INLINE_EMITTED` from `DICT_IDX`. No pre-pass; single walk of the zval tree as before.

    A count-then-emit variant was tried first: pre-walk the zval tree to tag occurrences, then emit inline for singletons and dict for repeats. The pre-pass cost ~200 ns per string and ate the per-singleton savings, so the single-walk upgrade-on-second-encounter version above is what ships. `rowset_1000` encode landed at 25% faster than igbinary (up from 8% in the pre-upgrade implementation), with payload size dropping from +5% to +2.7%.
7. **Skip refcount machinery during build.** All zvals built during decode are fresh and unshared until handed back to PHP. Internal writes can skip `Z_TRY_ADDREF` guards.

Local dev build
---------------

[](#local-dev-build)

The hand-rolled `Makefile` builds against an in-tree `~/php-src-8.4-opt`checkout without `phpize`/`autoconf`. Useful for hacking on the extension while also hacking on PHP itself:

```
make -j$(nproc)           # builds modules/phpser.so
make test                 # runs tests/*.phpt via run-tests.php
```

Override `PHP_SRC=` to target a different in-tree PHP checkout. Load alongside igbinary for the A/B bench:

```
~/php-src-8.4-opt/sapi/cli/php \
  -d extension=$HOME/igbinary/modules/igbinary.so \
  -d extension=$(pwd)/modules/phpser.so \
  bench.php
```

The `config.m4` auto-detects the session extension and registers phpser as a `session.serialize_handler` when available.

Limitations / known gaps
------------------------

[](#limitations--known-gaps)

- **Recursion depth is capped at 512** on both encode and decode. On decode, anything deeper than 512 nested containers / refs is rejected (returns `null`) to bound stack consumption against adversarial wire payloads. On encode, input deeper than 512 throws an `Exception` rather than silently shipping a truncated payload. Object cycles are preserved correctly via the id-table machinery and don't count against this cap for shared-graph cases; the cap only fires on genuinely deep trees. Cache workloads typically nest 5-10 deep, so the cap is many orders of magnitude past any legitimate payload.
- **Closures and resources encode as `NULL`.** Same shape as PHP's own `serialize()`; these types are inherently non-serializable.
- **Unknown classes at decode fall back to `stdClass`** rather than PHP's `__PHP_Incomplete_Class`. This is deliberate for the typical cache workload; `allowed_classes => [...]` produces `__PHP_Incomplete_Class`with the original name preserved for disallowed classes, matching PHP.
- **`session.serialize_handler=phpser` is shipped** (compiled in when `phpize` detects the session extension; gated on `HAVE_PHP_SESSION` so the extension still loads on session-less PHP builds). `phpredis`integration is not yet wired; call `phpser_serialize`/`unserialize`directly when using the extension as a phpredis serializer.

Wire format (V1)
----------------

[](#wire-format-v1)

```
[u8 version=0x01]
[varint ndict]
  per entry: [varint len] [bytes]
[value]

value tags:
  0x00 NULL
  0x01 FALSE
  0x02 TRUE
  0x03 LONG            varint (zigzag-encoded)
  0x04 DOUBLE          8 bytes (LE)
  0x05 STR_DICT        varint dict_idx
  0x06 ASSOC           varint(len), N×(key, val)
  0x07 PACKED_MIXED    varint(len), N×val
  0x08 PACKED_LONGS    varint(len), N×zigzag-varint
  0x09 PACKED_DOUBLES  varint(len), N×8-byte LE
  0x0a OBJECT          varint(class_idx), varint(nprops), N×(key_idx, val)
  0x0b PACKED_STRINGS  varint(len), N×varint(dict_idx)  // typed string run
  0x0c STR_INLINE      varint(len), bytes  // single-use string, skips dict
  0x0d ENUM            varint(class_idx), varint(case_name_idx)
  0x0e OBJECT_MAGIC    varint(class_idx), value  // class with __serialize;
                       // value is the array __serialize returned
  0x0f OBJECT_LEGACY   varint(class_idx), varint(len), bytes  // class with
                       // ce->serialize / ce->unserialize (Serializable etc.)
  0x10 REF             varint(id)  // back-ref to a previously-emitted container
  0x11 NEW_REF         value  // claims the next id for an IS_REFERENCE wrap

key tags:
  0x00 LONG            varint(zigzag)
  0x01 STR             varint(dict_idx)
  0x02 STR_INLINE      varint(len), bytes

```

Varints are LEB128 (unsigned); signed values use zigzag encoding. Tags 0x10/0x11 plus 0x0a/0x0d/0x0e/0x0f each implicitly claim the next id in encounter order, so the decoder reconstructs back-refs by counting container tags as it parses.

🔗 PHP Performance Toolkit
-------------------------

[](#-php-performance-toolkit)

Companion native PHP extensions:

- [php\_excel](https://github.com/iliaal/php_excel): native XLS/XLSX read/write via LibXL
- [mdparser](https://github.com/iliaal/mdparser): native CommonMark + GitHub Flavored Markdown parser
- [php\_clickhouse](https://github.com/iliaal/php_clickhouse): native ClickHouse client over the binary protocol
- [fastchart](https://github.com/iliaal/fastchart): 26 chart types in one PHP extension
- [fastjson](https://github.com/iliaal/fastjson): drop-in faster `ext/json`, backed by yyjson
- [statgrab](https://github.com/iliaal/statgrab): system statistics wrapper around libstatgrab

---

[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!

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance98

Actively maintained with recent releases

Popularity7

Limited adoption so far

Community9

Small or concentrated contributor base

Maturity40

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 90.3% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

20d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/2838354?v=4)[iliaa](/maintainers/iliaa)[@iliaa](https://github.com/iliaa)

---

Top Contributors

[![iliaal](https://avatars.githubusercontent.com/u/158724?v=4)](https://github.com/iliaal "iliaal (65 commits)")[![claude](https://avatars.githubusercontent.com/u/81847?v=4)](https://github.com/claude "claude (7 commits)")

---

Tags

cacheperformancephpphp-extensionpieserializationperformanceserializationcachebinaryphp-extensionpieigbinary

### Embed Badge

![Health badge](/badges/iliaal-phpser/health.svg)

```
[![Health](https://phpackages.com/badges/iliaal-phpser/health.svg)](https://phpackages.com/packages/iliaal-phpser)
```

###  Alternatives

[spatie/laravel-responsecache

Speed up a Laravel application by caching the entire response

2.8k8.7M64](/packages/spatie-laravel-responsecache)[putyourlightson/craft-blitz

Intelligent static page caching for creating lightning-fast sites.

155480.1k35](/packages/putyourlightson-craft-blitz)[alekseykorzun/memcached-wrapper-php

Optimized PHP 5 wrapper for Memcached extension that supports dog-piling, igbinary and local storage

2987.3k1](/packages/alekseykorzun-memcached-wrapper-php)[anahkiasen/flatten

A package for the Illuminate framework that flattens pages to plain HTML

33113.0k](/packages/anahkiasen-flatten)[aplus/cache

Aplus Framework Cache Library

171.6M4](/packages/aplus-cache)[rarst/fragment-cache

WordPress plugin for partial and async caching of heavy front-end elements.

14115.3k2](/packages/rarst-fragment-cache)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
