Download the PHP package lisachenko/php-shared-data-extension without Composer

On this page you can find all versions of the php package lisachenko/php-shared-data-extension. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package php-shared-data-extension

Shared Data Extension for PHP

CI Latest Version

PHP objects that survive the request boundary. In pure PHP.

Every PHP request pays the same tax: rebuild the framework kernel, reboot the DI container, reparse the configuration — throw it all away, repeat. This library deletes that tax. Persist an object once per worker and get the very same, fully-initialized instance back on every subsequent request — no serialization, no cache round-trip, no C extension to compile. Just composer require and FFI.

Why you'll like it

The proof lives in CI: a FastCGI gate drives hundreds of real requests through one worker and asserts the object graph is built exactly once, survives every RINIT/RSHUTDOWN boundary with its cycles intact, and sheds every mutation — plus a 5000-cycle soak over a nested/diamond/cyclic graph that fails on a single leaked byte of request memory, and a second 5000-cycle soak that persists and drops a whole graph per cycle and fails if the process does not get its memory back.

Two features share one persistent module:

  1. Persistent PHP objects (above) — see demos/demo-objects.php.
  2. Shared C data (the original demo): module globals with a raw C structure surviving the request boundary — counters, flags, fixed-size tables. See demo.php.

Feature map

What Where
Persistent objects per worker, frozen by default How it works, PersistentStore::boot()
One mmap arena shared by a whole fork tree Fork-shared arena mode, PersistentStore::bootShared()
Mutation the whole family sees, opt-in per graph Shared mutation, mutableHandle()
Channels, shared arrays, result slots, locks, counters, wait groups IPC primitives, Lisachenko\SharedData\Ipc
Closures invoked in several workers (registered before the fork) Shared closures, Ipc\ClosureProvenance
Why any of it is shaped this way — the laws, the measurements, the failure modes docs/shared-memory-model.md
The rules a contributor (human or agent) must not break AGENTS.md

How it works

persist(ClassName::class, $object) deep-converts the whole object graph reachable from that root into persistent (malloc) memory and returns a new canonical root instance:

Per request the store walks its global object table once and re-registers every persisted object in EG(objects_store) (fresh handle each via zend_objects_store_put), rebinds each object's class entry by name with a per-object layout-signature guard (a graph may mix classes), then materializes the canonical root instance of every entry — the rest of each graph is reached through property slots pointing at the very same pinned clones. An object shared by several entries is handled exactly once. At request shutdown — before the engine tears the object store down — every object is rolled back to its own persisted snapshot and detached, so the engine never touches persistent memory with the request allocator.

Frozen semantics

Persisted state is frozen graph-wide: you can mutate the root and any nested object freely during a request (mutations land in request memory), but at request end every property of every graph object is rolled back to the state captured by persist() — including slots you repointed at brand-new objects. To change the persisted state, call persist($name, $newObject) again with fresh state. Mutation sync-back is planned as an opt-in mode.

What can be persisted

Objects of userland classes with scalar, string, array and object properties (nested arrays and nested object graphs welcome). The persister rejects — with the exact property path, e.g. $root::$services[db]::$pdo — anything whose identity or lifetime cannot outlive a request:

Rejected Why
Resources tied to request-scoped handles
Closures internal class carrying request-bound scope — share one with Ipc\ClosureProvenance instead of persisting it
References not supported yet
Internal classes (ArrayObject, stdClass, …) carry C state the engine frees per request
Enums enum case identity is per-request
Dynamic properties no stable slot to persist into
Lazy objects / hooked classes non-standard handlers or engine flags
Persistent objects from a foreign registry no record accounts for their lifetime (objects of this store are shared, not rejected)

Sharing objects between graphs

A persisted object is a first-class value: it can be stored in a plain request object's property, passed around, and wired into another graph you persist later. The persister recognizes it and references the existing clone instead of copying it.

Every persistent object counts how many entries reference it (shares). persist() increments the count of every member of the new graph before the previous generation of that key is released, so an object present in both never transits through zero. The same root can also be filed under several keys — two entries, one fully shared graph.

Foreign persistent objects — clones minted by a different registry, e.g. another module instance — are still rejected: nothing here can account for their lifetime.

Dropping persisted entries

drop() removes the entry and gives the process its memory back: for every member no other entry still references, it frees the object clone, its frozen snapshot buffer, every sealed array hashtable it owns (nested arrays included) and all of the registry bookkeeping around it. Shared members survive with their share count decremented. Persisting over an existing key is the same operation with a new graph put in place first, so a long-running worker that re-persists a key does not accumulate generations.

Alias safety. Userland copies of an object zval bump the refcount even on a pinned persistent clone, so a live alias is detectable. If anything in the current request still holds an object that drop() would free, it throws a RuntimeException naming the class and changes nothing at all — release the references (unset() them, or let their scope end) and drop again:

Array and string payloads are NOT covered by that check. Immutable arrays and persistent strings live in non-refcounted zvals — that is what makes them zero-copy to read — so a copy taken earlier in the same request leaves no trace behind. After drop() returns, do not use copies of that entry's array or string values taken earlier in the same request. Across requests the question cannot arise: request memory dies with its request.

What is not reclaimed. Persistent strings (property values, array keys and elements, class names, registry keys) are deliberately never freed, for exactly the reason above: nothing can prove a request-side copy is gone. They are also not deduplicated, so the leak is proportional to the number of strings persisted over the process lifetime — roughly 4 kB per persist/drop cycle of a five-object graph in tools/soak-drop.php, against ~13 kB per cycle if nothing were reclaimed. A real content-keyed persistent intern table would remove this residue; it is the next iteration.

Fork-shared arena mode (opt-in, experimental)

Everything above is per-worker memory: each FPM/RoadRunner process rebuilds its own copy. Arena mode removes that limit for a family of processes that descend from one parent. The state is persisted into a single mmap(MAP_SHARED|MAP_ANONYMOUS) region created before the fork, so every worker sees the very same objects at the very same addresses — no serialization, no cache round-trip, no copy.

What changes under the hood: every block the store mints — registry tables, object clones, frozen snapshots, interned strings, sealed arrays and their keys — comes out of the arena instead of malloc. The registry tables are pre-sized and never grown: the engine grows a full hashtable by reallocating its data block into the private heap of whichever worker filled it, writing that pointer into the shared struct before anything fails, so the tables refuse the insert with a typed ArenaException instead. Sizes come from SHARED_DATA_ENTRY_CAPACITY / SHARED_DATA_OBJECT_CAPACITY.

The arena is bump-allocated and leak-until-teardown: blocks are never returned individually (drop() still removes entries and share-accounts them, it just does not free arena memory), and the region lives until the creating process exits — nothing unmaps it at request shutdown, because the engine releases the last references to shared objects after shutdown functions have run. watermark() exposes exactly how much has been handed out, and exhaustion is a typed exception, never a crash. Cross-process locking uses a bank of 64 PTHREAD_PROCESS_SHARED | PTHREAD_MUTEX_ROBUST mutexes inside the arena, so a SIGKILLed worker hands the lock on (EOWNERDEAD) instead of wedging the pool.

Per-process engine state. Three fields of a zend_object describe the process reading it, not the object, and they live in a per-process side table rather than in shared memory: the object-store handle (forked children inherit one free list and are handed identical numbers, so the shared field is overwritten with a sentinel and identity is $store->sharedIdOf($object) — the arena address), the class entry (rebound per process; classes must still be loaded before the fork, since a shared object carries one ce for the family), and the dynamic-property cache. That last one is written by engine C code on get_object_vars(), var_dump(), json_encode(), (array), serialize(), debug_zval_dump() and ReflectionObject — a request-heap pointer deposited in shared memory — so it is forced NULL at attach and never dereferenced. Inspect a shared object through $store->inspect($object, fn ($o) => var_dump($o)), or call $store->scrubProperties($object) afterwards.

Shared mutation (opt-in per graph)

By default a persisted graph is frozen: request-time mutations are rolled back at request end. Pass mutable: true and the graph keeps everything that makes a persistent clone safe — the refcount pin, GC_PERSISTENT|GC_NOT_COLLECTABLE, non-refcounted payloads, sealed arrays — and gives up the rollback, so what a worker writes stays written for the whole family:

Every write takes the object's stripe mutex and does nothing inside it but store the payload word and then the type word; every value is validated and interned before the lock. Declared property types are enforced by the write path, because the engine never sees the assignment. What is refused: a plain-array slot (a shared zend_array can never grow — use Ipc\SharedArray), and a reference to an object that is not itself in this arena.

A direct $object->hits++ still compiles and still reaches shared memory — the extension rewires shared objects to std_object_handlers, so there is no write hook to intercept it. For scalars that is merely unsynchronized (visible everywhere, racy). For a string, array or object it stores a pointer into the writing process's request heap, which no sibling may follow: such a slot is restored from the persisted image at detach instead of being left behind. Use the handle for anything that has to be correct.

Reader/writer contract for anything you build on the arena directly: a naturally aligned 8-byte read never tears, but a 16-byte zval is two stores — readers take the same stripe mutex as the writer whenever a value's type can change or more than one slot participates. Every claim in this section, with its evidence and its consequences, is written up in docs/shared-memory-model.md.

IPC primitives in the arena (experimental)

Shared memory answers "where does the value live"; it says nothing about "whose turn is it" and "is it there yet". Lisachenko\SharedData\Ipc adds the primitives that do, and they are themselves structures in the arena — a channel, an array, a mutex, a counter, a wait group and a table of result slots, all found by address (or by a name in the arena roots directory) rather than inherited as PHP state.

Slots are recycled, and a slot id is a ticket rather than an index. The arena is bump-only and nothing frees a block, so releaseSlot() reuses the slot record in place through a free list threaded through the slots themselves. What makes that safe is the generation packed beside the index in every id (Ipc\SlotTicket, 16 bits each, so the whole thing still fits the uint32 a wake event carries): the generation moves the instant an owner releases its claim, and reading, awaiting, completing or releasing a slot with an out-of-date ticket is an IpcException naming the slot and both generations — never another task's result. A slot nobody releases simply stays out of circulation, and a slot whose 16-bit generation is used up is retired rather than wrapped, so no handle is ever revived by a counter coming back round.

Values move as 16-byte records: uint8 tag | 7 pad | uint64 payload, where the payload is the value itself (int, float, nothing at all for null/bool) or an arena address (an interned zend_string, a shared zend_object, a SharedArray, the record of a shared closure). A value with no address-shaped form — a plain array, a resource, an object this family does not share, a closure nobody registered — is refused with NotShareableValueException naming the remedy. Nothing is ever encoded: there is no serialize(), igbinary or JSON on any data path, and the test suite proves it by shadowing every encoding function in the package's namespaces.

The sockets carry only fixed 16-byte event records {opcode, tag, slot/channel id, address} — signalling, never payload; a scalar's record carries a zero where an address would be. Waking is level-triggered: a waiter registers in the structure's waiter table and re-checks the state inside the same critical section, so a wakeup can be spurious but never lost, and every blocking loop also re-polls on a bounded slice.

Primitive What it is
SharedChannel ring of records + waiter tables under a dedicated robust mutex; capacity 0 is a true cross-process rendezvous; close() crosses processes (receivers drain, then [null, false]; senders throw)
SharedArray fixed-capacity vector of records, ArrayAccess/Countable/IteratorAggregate, stripe-locked
ResultSlotTable futures: allocateSlot() / complete() / completePanic() / await() / releaseSlot(), ids carrying a generation so a recycled slot never answers an old handle, and panics travelling as a shared SharedError object
SharedMutex robust process-shared mutex with trylock-and-backoff, EOWNERDEAD recovered and reported
AtomicInt one shared cell: plain aligned get/set, stripe-locked add()/compareAndSet()
SharedWaitGroup counter plus waiter table; add()/done()/wait(), negative counts throw
WakeRegistry one inherited socket pair per process, the notification plane everything parks on

Blocking here is a spin loop over the notification descriptor, which is the honest primitive a package with no scheduler can offer: every primitive also exposes its non-blocking half (trySend()/tryRecv()/tryLock()/readSlot()) plus notificationStream(), so a coroutine runtime can park a Fiber in its own event loop instead.

A capacity-0 channel needs one thing more, because its gate is "is a receiver waiting" and a consumer with its own scheduler is never inside recv():

The registration is a claim about presence, never about storage — the record goes into the one ring slot a capacity-0 channel allocates — so cancelReceiver() never has a value in its hands and can always succeed. A registration can outlive its process, so each waiter entry carries its owner pid and a rendezvous deposit reaps the dead ones before it reads the gate.

Shared closures (registered before the fork)

A closure compiled before the fork is valid in every worker: the family inherited the memory it lives in, so its address means the same function everywhere. A closure compiled after the fork is the opposite, and it does not fail loudly — a stale address was observed holding a different, perfectly valid Closure that then executed the wrong function. Nothing about the object tells the two apart, so this package decides on provenance and never on inspection: a closure travels if, and only if, it was registered before the fork barrier.

The record — closure address, function witness, name, bound $this — lives in the arena; the closure itself is never copied. What is refused, with the reason named: registering after the barrier or from a worker, a bound $this that is not a shared object, a captured plain array or request object, a capture by reference and a declared static variable (each worker would copy-on-write its own copy of those slots and diverge in silence). Cloning post-fork closures into the arena is a separate problem with its own verdict — docs/closure-cloning.md.

Deployment model

Introspection

The module surfaces its state in phpinfo() / php -i (persisted entry names, entry count and the number of live object clones in the shared_objects section; PersistentStore::objectCount() returns the same number) and declares an engine-enforced dependency on ext-ffi. At request end a module-level requestShutdown() callback acts as a belt-and-braces detach on top of the store's own shutdown function.

API

Testing

CI runs all of the above on every push and pull request, on PHP 8.4 and 8.5. spikes/ holds the runnable evidence behind the engine claims this package rests on; AGENTS.md states the rules a change has to keep.

Requirements


All versions of php-shared-data-extension with dependencies

PHP Build Version
Package Version
Requires lisachenko/z-engine Version ~8.4.2 || ~8.5.0
php Version ^8.4
ext-ffi Version *
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package lisachenko/php-shared-data-extension contains the following files

Loading the files please wait ...