Cache Per-Object Data Without Leaks Using PHP WeakMap

PHP WeakMap keys a cache by object without blocking garbage collection, so per-object data frees itself. See WeakMap vs SplObjectStorage with clear examples.

Steven Richardson
Steven Richardson
· 7 min read

You have a service that does something expensive per object — rendering a report, pricing an order, resolving permissions for a user — and you want to compute it once. So you cache the result in an array keyed by spl_object_id(), or you reach for SplObjectStorage. Both quietly leak memory in a long-running worker. PHP's WeakMap is the data structure built for exactly this, and it has shipped since PHP 8.0.

The leak: caching by object the naive way#

Here is the pattern almost everyone writes first. Cache an expensive result against the object's id:

<?php

class ReportBuilder
{
    /** @var array<int, string> */
    private array $cache = [];

    public function render(Report $report): string
    {
        $id = spl_object_id($report);

        // Compute the expensive render once, then reuse it.
        return $this->cache[$id] ??= $this->expensiveRender($report);
    }
}

This has two problems, and the second one is nasty. First, nothing ever removes entries from $cache, so it grows for the entire life of the process. Second, spl_object_id() is only unique among currently live objects. Once a Report is destroyed, PHP is free to hand that same id to a brand-new object — and now render() returns another object's cached string. That is a silent correctness bug, not just a leak.

Reaching for SplObjectStorage fixes the id-reuse bug:

<?php

$cache = new SplObjectStorage();

function render(SplObjectStorage $cache, Report $report): string
{
    if (! isset($cache[$report])) {
        $cache[$report] = expensiveRender($report);
    }

    return $cache[$report];
}

But it introduces a worse one. SplObjectStorage holds a strong reference to every object you use as a key. So every Report you ever pass in is pinned in memory for as long as $cache lives. In a classic PHP-FPM request that dies at the end of the request and nobody notices. In an Octane or queue worker that stays resident across thousands of jobs, it is a genuine leak — memory climbs until the worker is recycled or the OOM killer steps in.

PHP WeakMap in one example#

A WeakMap is a map that takes objects as keys, but the key does not count toward the object's reference count. The moment the last real reference to the object goes away, the object is collected and its entry vanishes from the map. No cleanup code required.

<?php

$cache = new WeakMap();

$report = new Report();
$cache[$report] = 'rendered output';

var_dump(count($cache)); // int(1)

unset($report);          // drop the last strong reference

var_dump(count($cache)); // int(0) — entry collected with the object

That is the whole idea. The WeakMap never stopped the garbage collector from reclaiming $report, so the cached value went with it. You get memoization that cleans up after itself, keyed by object identity rather than a reusable integer.

WeakMap is final and implements ArrayAccess, Countable, and IteratorAggregate, so in day-to-day code it behaves like an associative array whose keys happen to be objects.

WeakMap vs SplObjectStorage#

They look similar — both store data against object keys — but they differ in the two ways that matter.

The first is reference strength, which I have already covered: SplObjectStorage keeps its keys alive, WeakMap does not. That single difference is the reason to pick WeakMap for any cache whose entries should not outlive the objects they describe.

The second is iteration, which trips people up:

<?php

$obj = new stdClass();

$storage = new SplObjectStorage();
$weak    = new WeakMap();

$storage[$obj] = 'meta';
$weak[$obj]    = 'meta';

// SplObjectStorage: the loop variable is the OBJECT; data comes from getInfo().
foreach ($storage as $object) {
    var_dump($storage->getInfo()); // string(4) "meta"
}

// WeakMap: iterates like a normal map — object key, arbitrary value.
foreach ($weak as $object => $value) {
    var_dump($value);              // string(4) "meta"
}

SplObjectStorage still has real uses — it is a genuine set with attach(), detach(), and contains(), and sometimes you want to hold objects alive. But when your goal is "store some derived data for this object and forget about it when the object is gone," WeakMap is the cleaner tool.

Memoize per-object data with a WeakMap#

Here is the pattern I actually reach for: a tiny reusable memoizer. The brief for this piece suggested a trait, but a small helper class composes better and keeps the WeakMap fully encapsulated.

<?php

final class Memoizer
{
    /** @var WeakMap<object, mixed> */
    private WeakMap $store;

    public function __construct()
    {
        $this->store = new WeakMap();
    }

    /**
     * Return the memoized value for $object, computing it once via $compute.
     *
     * @template T
     * @param  callable(): T  $compute
     * @return T
     */
    public function remember(object $object, callable $compute): mixed
    {
        return $this->store[$object] ??= $compute();
    }
}

Drop it into a service that runs an expensive calculation per model:

<?php

class PricingService
{
    private Memoizer $memo;

    public function __construct()
    {
        $this->memo = new Memoizer();
    }

    public function totalFor(Order $order): Money
    {
        // The heavy calculation runs once per Order, then reuses the result.
        return $this->memo->remember($order, fn () => $this->calculateTotal($order));
    }

    private function calculateTotal(Order $order): Money
    {
        // ...expensive: sum line items, apply tax rules, discounts, FX conversion...
    }
}

PricingService is usually a singleton, so $memo lives for the whole request — or the whole worker lifetime under Octane. With a plain array you would pin every Order for that entire span. With the WeakMap, each Order that falls out of scope quietly takes its cached total with it, even though the service and its map live on. That is memoization that stays bounded by the objects currently in flight.

Gotchas and edge cases#

Keys must be objects. $map[42] = 'x' throws a TypeError. WeakMap is specifically for object-keyed data — if you are caching by a scalar id, a plain array or the Cache facade is what you want.

Enum cases never get collected. A backed or pure enum case is effectively a singleton the engine holds a reference to for the life of the request. Use an enum case as a WeakMap key and its entry will never disappear on its own — you would have to unset() it manually. Do not lean on a WeakMap to auto-expire enum-keyed data.

Membership is not a liveness signal. An object can still be in the map because some other part of your code holds a reference to it, not because it is fresh or valid. If you iterate a WeakMap of, say, ORM entities and assume everything in there is still managed, you will eventually hit a detached-entity surprise. The map tells you the object exists, nothing more.

It is process-scoped. A WeakMap lives and dies with the PHP process. It is not shared across workers and it is not a cross-request cache. Under Octane it survives between requests, which is fine — it stays bounded by live objects — but if you need data to persist across requests or across a fleet, that is a job for Redis or the Cache facade. And even with a well-behaved WeakMap, recycle your workers with max-jobs and max-time so ordinary PHP memory creep never gets the chance to accumulate.

Wrapping up#

Use a WeakMap whenever you want to attach computed or derived data to an object and have that data disappear automatically when the object does. It kills the two failure modes of the naive approaches in one move: no unbounded growth, and no spl_object_id reuse returning stale data. Start by wrapping the small Memoizer above around your most expensive per-object computation and watch a whole class of worker-memory noise vanish.

From here, keep leaning on the modern PHP features worth adopting before you upgrade, and if your leaks live in the queue rather than the request, the production queue scaling guide is the natural next read.

FAQ#

What is a WeakMap in PHP?

WeakMap is a built-in PHP 8.0 class that maps objects to arbitrary values. Unlike a normal array or SplObjectStorage, the object used as a key is held by a weak reference, so it does not stop the garbage collector from reclaiming that object. When the object is collected, its entry is removed from the map automatically.

When should I use WeakMap instead of an array?

Use a WeakMap when your cache key is an object and the cached data should not outlive that object. Arrays can only key on strings or integers, which forces you to derive an id and then manage cleanup yourself. A WeakMap keys on the object directly and handles removal for you, which is ideal for memoizing per-object results inside a request or a long-running worker.

What's the difference between WeakMap and SplObjectStorage?

Both store data against object keys, but SplObjectStorage holds a strong reference and keeps its keys alive, while WeakMap holds a weak reference and lets them be collected. That makes SplObjectStorage a good object set when you want to retain objects, and WeakMap the right choice for caches that should clean themselves up. They also iterate differently: SplObjectStorage exposes data via getInfo(), whereas WeakMap iterates like a normal key-value map.

Does WeakMap prevent garbage collection?

No — that is the entire point. A WeakMap key does not contribute to the object's reference count, so if the only remaining reference to an object is its WeakMap key, the object is still garbage collected and the entry is dropped. This is the opposite of SplObjectStorage and plain arrays, which keep referenced objects alive.

Can I use WeakMap to cache Eloquent model data?

Yes, for request-scoped or worker-scoped memoization — for example, computing an expensive value once per model instance within a single request. It is not a replacement for Laravel's cache layer: a WeakMap is not shared across requests, workers, or servers, and it disappears when the process ends. Use it to avoid recomputing within a process, and use Redis or the Cache facade when the result needs to persist.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.