Skip to content

Repository files navigation

Laravel Cache Memory

CI Latest Version Downloads PHP Version License

laravel-cache-memory

A Laravel cache driver backed by System V shared memory (shmop). One cache segment on the host, shared by every PHP process on it: PHP-FPM workers, artisan commands, queue workers. No Redis, no Memcached, no socket. Meant for small, hot, single-box caches (rate-limit counters, feature-flag snapshots, warm lookup tables) where an external cache service is overkill. Atomic increment(), decrement() and add() (with a TTL), and Cache::lock() without Redis.

// config/cache.php
'memory' => ['driver' => 'memory', 'key' => 771055301, 'size' => 320000],

// a queue worker increments...
Cache::store('memory')->increment('signups:today');

// ...and the next web request sees it. Exact under contention:
// 16 processes x 50 increments x 6 rounds == 4800 every time.

Why not APCu? Laravel's built-in apc driver is the right answer when only your web workers need the cache. APCu memory belongs to one process family: the PHP-FPM pool shares it, but every CLI process (artisan, queue:work, a cron job) gets its own private, empty APCu that dies with the process. A counter bumped from a queue worker is invisible to the web request. A SysV segment is keyed by number, not by process, so anything on the host that opens the same key sees the same bytes.

What it is not for. The whole store is serialised into one fixed-size segment and rewritten on every write, so it is a poor fit for large or write-heavy caches. Read Limitations before using it in production, especially the overflow behaviour and how it discards locks.

Table of Contents

Requirements

  • PHP 8.2+
  • ext-shmop
  • ext-sysvsem
  • Laravel 11 or 12 (illuminate/cache and illuminate/support ^11.0|^12.0)

Note

Laravel 11 reached end of life in 2026 (no more security fixes). The package still supports and tests it, but new projects should target Laravel 12.

Installation

composer require sanchescom/laravel-cache-memory

The service provider (Sanchescom\Cache\MemoryServiceProvider) is registered automatically via package auto-discovery — no manual registration needed.

Quick Start

Add a memory store to config/cache.php:

// config/cache.php
'stores' => [
    // ...
    'memory' => [
        'driver' => 'memory',
        'key' => env('MEMORY_BLOCK_KEY', 771055301), // pick your OWN value — see below
        'size' => env('MEMORY_BLOCK_SIZE', 320000),  // bytes; omit to use the 320000-byte default
    ],
],

Change that key. System V IPC keys are a single host-wide namespace shared by every process on the machine, so two applications configured with the same key attach to the same segment and silently read and overwrite each other's cache entries — pick a distinctive value per application (and per environment, if several run on one host) rather than copying the one above.

Then use it like any other Laravel cache store:

use Illuminate\Support\Facades\Cache;

// One process writes...
Cache::store('memory')->put('some_key', ['value' => 'text'], 60); // 60-second TTL

// ...another process on the same host reads it back.
$data = Cache::store('memory')->get('some_key');

// Counters are exact under concurrent access (see Atomicity below).
Cache::store('memory')->increment('hits');

Configuration

Each configured store gets its own isolated shared memory segment and its own semaphore — two stores never share state, even if both use the memory driver.

Key Type Default Notes
driver string Must be memory.
key int|null derived via ftok() SysV IPC key. Cast to int by the provider. See the [!WARNING] below about leaving this unset in production.
size int|null 320000 (MemoryBlock::DEFAULT_SIZE) Total segment size in bytes, including a 4-byte internal header. Cast to int by the provider.

If key is omitted, MemoryBlock derives one from ftok(__FILE__, 'b') — i.e. from the installed package file's device and inode, not from anything you configure. If size is omitted, the segment is sized at MemoryBlock::DEFAULT_SIZE (320,000 bytes).

How It Works

  • Shared memory segment. Each store owns one shmop segment sized by size (or the 320,000-byte default). All keys for that store — the entire cache — live serialized inside this one fixed-size segment.
  • Length-prefixed framing. A write packs a 4-byte big-endian length header (pack('N', strlen($payload))) followed by the serialized payload. Reads trust that header rather than scanning for a terminator, which is what makes a read of a segment mid-write detectable instead of silently truncated.
  • One semaphore per store, paired to the segment's key. The store's constructor calls sem_get($key, 1, 0644, false) against the same key used for the shared memory segment — the same permissions as the segment itself, so the lock is no easier to grab than the data it guards. Every public operation on the store — get() included — acquires this semaphore before touching the segment and releases it afterward.
  • Garbage collection on overflow. When a write no longer fits, expired entries are purged first and the write is retried. If it still doesn't fit, the whole cache for that store is written back empty into the same segment and an E_USER_WARNING is raised, in that order (see Limitations — this is destructive and intentional, not a bug). The segment itself is never deleted on this path; only requestDeletion() does that.

Atomicity

Every store operation — reads included — runs inside the semaphore-guarded critical section described above, which is what makes increment()/decrement() exact under concurrent access instead of merely "usually correct." This is proven by a Pest test that forks 16 child processes, each incrementing the same counter 50 times, across 6 independent rounds, and asserts the final count equals exactly 16 × 50 every round. It's a real net, not a rubber stamp: run against a deliberately reverted version of the locking fix, it caught the regression in 40 out of 40 runs.

Note

That test has been verified repeatedly on macOS during development. CI (.github/workflows/ci.yml) is this package's first data point on Linux — nothing here has been separately verified on Linux outside CI.

Locks

The store implements Laravel's LockProvider, so Cache::lock() works on it and follows the usual API:

$lock = Cache::store('memory')->lock('report:nightly', 120);

if ($lock->get()) {
    try {
        // exclusive across every process on the host
    } finally {
        $lock->release();
    }
}

// block up to 5 seconds for the lock, then run and release automatically
Cache::store('memory')->lock('report:nightly', 120)->block(5, function () {
    // ...
});

// release from another process (a queue job that finishes the work)
Cache::store('memory')->restoreLock('report:nightly', $owner)->release();

A lock is an entry in the store under the lock's name, holding the owner token. acquire() goes through the atomic add() (also when seconds is 0, unlike the framework's generic CacheLock), and release() checks the owner and removes the entry in one critical section, so an expired lock re-acquired by another process cannot be released by the old holder. Mutual exclusion is covered by a forked test: 16 processes each perform 20 non-atomic read-modify-writes under the lock and the counter ends at exactly 320.

Warning

Lock names share the key space with cache keys (as with every Laravel cache-backed lock): Cache::put('job', …) and Cache::lock('job') use the same entry. flush(), requestDeletion() and the overflow path discard held locks together with the data. refresh() is not supported.

Stats

Cache::store('memory')->getStore()->stats();
// ['key' => 771055301, 'capacity' => 319996, 'used' => 1842, 'free' => 318154, 'items' => 12, 'expired' => 3]

used is the serialised size of the whole store; expired entries are counted, not purged. The same numbers as a table:

php artisan memory:stats            # the "memory" store
php artisan memory:stats hot        # any other memory-driver store

Limitations

Warning

Fixed segment size. The segment never grows. When a write would exceed the configured size, expired entries are garbage-collected first. If the write still doesn't fit, the entire store is written back empty and only then is an E_USER_WARNING raised. The order matters, because that warning does not behave the same everywhere:

  • Inside a booted Laravel application, HandleExceptions promotes E_USER_WARNING to a thrown ErrorException, so the overflowing call does not returnCache::put() throws, and your exception handler (and your logs) see it.
  • Outside Laravel — plain PHP, or any handler that swallows the warning — the same call returns normally, and put() returns true.

The state left behind is identical in both worlds: the store is empty and immediately usable again. The value you just stored, and everything else in that store, is gone. Size the store for your real working set, and treat this warning as a sizing bug in your configuration, not as routine cache pressure.

Warning

requestDeletion() disconnects other live processes from the cache. Deleting and recreating a segment produces a new segment under the same key. Any other process that already holds an open handle keeps reading and writing the old, orphaned one for the rest of its life: it only reopens when the key has no segment at all, and it has one again immediately. Both sides still share the same semaphore, so their accesses stay perfectly serialised while their data diverges — which looks exactly like a locking bug and is not one. Call requestDeletion() only when nothing else is using the store, and restart your workers afterwards. (This is why the overflow path above empties the segment instead of recreating it.)

Warning

flush(), requestDeletion() and overflow discard locks too. Locks are entries in the same segment as the data (see Locks); any path that empties the store releases every held lock at once.

Warning

Data is lost on reboot, or whenever the segment is deleted. Shared memory is not persistent storage — it does not survive a host restart, and both requestDeletion() and the overflow path above discard everything the store held.

Warning

POSIX only — no Windows support. ext-shmop and ext-sysvsem are System V IPC facilities; there is no Windows shmop/sysvsem implementation to fall back to.

Warning

The default key is not stable across symlinked deploy paths. With key left unset, the segment's SysV key comes from ftok(__FILE__, 'b') — derived from the installed package file's device and inode. A symlink-swap deploy layout (e.g. Capistrano-style releases/<n> + current symlink) copies fresh files into a new release directory on every deploy, giving that file a new inode and therefore a new default key each time you deploy — silently disconnecting old worker processes from new ones. Set an explicit key in production.

Warning

Segment and semaphore permissions are 0644 — same-user processes only. Both the shared memory segment and its paired semaphore are created owner-read-write, group/other-read-only. Only processes running as the same user that created them can write to the segment or acquire the lock; processes running as a different user can, at best, read the segment. There is currently no way to configure this.

Additionally: the paired semaphore is not removed by requestDeletion() or MemoryBlock::delete() — only the shared memory segment is. This is deliberate (see UPGRADE.md), but it means a long-lived host that cycles through many distinct keys accumulates one leftover semaphore per key. Reuse a stable, small set of keys.

Testing Your Application

For most applications, the right thing to do in tests is not to use the memory driver at all — set CACHE_STORE=array (or configure the array store directly) so tests don't touch real shared memory or leak SysV resources between runs.

If you specifically need to exercise the memory driver:

  • Give each test (or test process) its own unique key, so parallel test runs never collide on the same segment.
  • Call Cache::store('memory')->getStore()->requestDeletion() in teardown to drop the segment. Remember this leaves the semaphore in place (see Limitations) — if your test suite forks processes or runs many times against a fixed key, clean the semaphore up separately (sem_get($key) + sem_remove($semaphore)).
  • Also call Cache::purge('memory') after requestDeletion() so the resolved repository (and the Shmop handle it holds) is dropped too — deleting the segment does not detach your process from it, and a kept handle burns a SysV attachment slot per test until you hit "Unable to open shared memory segment". This package's own feature suite does exactly this in its afterEach().

Roadmap

See ROADMAP.md for planned features (configurable permissions and an APCu fallback driver).

Upgrading

See UPGRADE.md for breaking-change notes, including the 1.x → 2.0 migration.

Contributing

Please read CONTRIBUTING.md for details on our code of conduct, and the process for submitting pull requests to us.

Versioning

We use SemVer for versioning. For the versions available, see the tags on this repository.

Authors

See also the list of contributors who participated in this project.

License

This project is licensed under the MIT License - see the LICENSE.md file for details.

About

Laravel cache driver on System V shared memory (shmop): one cache shared by PHP-FPM and CLI/queue workers on the same host, no Redis. Semaphore-guarded atomic increments, proven under 16 concurrent processes.

Topics

Resources

Contributing

Stars

7 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages