In my previous article, I introduced a minimal identity map for Laravel Eloquent. While the implementation I went through is functional, and will be all that most people need, issues may arise when you have a process that's hydrating a large number of models, or if you're using Laravel Octane. If you haven't read that article yet, I recommend doing so, as this one builds on top of it.
May 21st 2025
A Minimal Identity Map for Laravel Eloquent
Eloquent is a powerful ORM, though it's not without its quirks. One of those quirks is the way it handles model instances, and if you're not careful, you can end up with multiple instances of the same model in memory. This can lead to unexpected behavior and bugs that are hard to track down. In this article, I'll show you how to implement a minimal identity map for Eloquent that will help you avoid these issues.
So, the big issue as the name of this article suggests, is memory usage. If you have a process that's hydrating hundreds or thousands of models, every one of those will be loaded into memory and kept there. Now, while I'm fairly certain that if you have a single process doing this, you have much bigger problems, I will admit that there are times where it's necessary, or at the very least, unavoidable. Plus, I'm a big fan of sharing knowledge and understanding, with no reason other than to do so.
But before we look into how to address this, let's look at why the problem we're trying to solve may come about in the first place.
Object References and Garbage Collection
The issue of memory usage comes down to two things; how PHP stores objects in memory, and how PHP frees up memory. The first uses references, and the second uses garbage collection.
Object References
Some of you will be familiar with PHPs
reference functionality, but I'm going to
cover it briefly here anyway. The &
operator in PHP allows you to
pass, return, or assign by reference, dependant on where you use it. This means that a variable created
with the operator, directly references the original, rather than copying it. It doesn't have its own value, it's
using the same one, so any change to one is directly reflected in the other.
Take the following code snippet,
where the b
variable is created using
a
, but modifying it does not affect the original.
$a = 5;
$b = $a;
$b++;
echo $a; // 5
echo $b; // 6
If you change the code to use the &
operator, you'll see that
modifying b
will also modify a
.
$a = 5;
$b = &$a;
$b++;
echo $a; // 6
echo $b; // 6
PHP handles objects in a similar way to using the &
operator,
though it's typically referred to as being reference-like. When
you create a variable using another that contains an object, the new variable doesn't contain a copy of the
object, nor does it contain a reference to the original variable. It contains a reference to the object itself,
as a value, not a variable.
If you change the object in one, it's
reflected in the other, as they both point to the same object in memory.
$a = new stdClass();
$b = $a;
$a->x = 5;
$b->x++;
echo $a->x; // 6
echo $b->x; // 6
Only changes to the object itself will be reflected in both variables. If you changed the value, say setting
a
to null
, then
b
will still point to the original object. Unless of course you're
using the reference operator.
Garbage Collection
Garbage collection is the process of freeing up memory that is no longer in use. If you want to read all the details about how PHP garbage collection works, there's a page in the docs . We don't need all the details now, we just need to know that PHP does something called reference counting, which lets it know if a value is being used. Every variable that contains a reference to another variable, or an object reference, counts towards the original values reference count. Once that reference count reaches zero, PHP knows that it can free up the memory it was using.
Because we're loading every eloquent model into the identity map, every single one of those models will have a reference count of at least one, so PHP cannot free up the memory used. It can't run the garbage collector on these values until they are no longer referenced. This means the identity map is essentially acting as a cache with no invalidation mechanism, which will lead to memory bloat over time. As well as memory leaks in environments like Laravel Octane, where the process is kept alive for longer than a single request.
Letting the Garbage be Collected
So the problem is that even when our code stops using the models, they're still being referenced by the
identity map. But how do we stop that, as that's how PHP works when dealing with objects? Fortunately, someone
already thought of this, and added a class to PHP called WeakReference
.
The class is created using an object, and lets you retrieve it, but it doesn't count towards that
objects
reference count. If the object is no longer used anywhere else, and it only remains in the weak reference
instance, it can be garbage collected.
So, to address our possible memory issues, we can make use of this class in the identity map. Fortunately, there's only actually two places where we need to change any code.
Using Weak References When Adding
The first change is in addToIdentityMap
, where instead of simply
adding the model
variable, we wrap it in a
WeakReference
.
public static function addToIdentityMap(self $model): void
{
if ($model->exists === false || $model->getKey() === null) {
throw new \InvalidArgumentException('Model must exist and have a key to be added to the identity map.');
}
self::$identityMap[$model::class][$model->getKey()] = WeakReference::create($model);
}
This ensures that the models presence in the identity map does not prevent the garbage collector from freeing up memory when the model is no longer used.
Checking Weak References When Retrieving
The second change is in getFromIdentityMap
, where we need to handle
that we're now dealing with an instance of WeakReference
.
public static function getFromIdentityMap(int|string $key, ?string $class = null): ?static
{
$weakRef = self::$identityMap[$class ?? static::class][$key] ?? null;
if ($weakRef instanceof \WeakReference) {
$model = $weakRef->get();
if ($model === null) {
unset(self::$identityMap[$class ?? static::class][$key]);
}
return $model;
}
return null;
}
This method is now a bit more complex than its original one liner, but let's go through what it does.
$weakRef = self::$identityMap[$class ?? static::class][$key] ?? null;
First thing we're doing is explicitly assigning the value from the identity map array to a variable. We're doing this because we need to check it.
if ($weakRef instanceof \WeakReference) {
$model = $weakRef->get();
if ($model === null) {
unset(self::$identityMap[$class ?? static::class][$key]);
}
return $model;
}
Next we're checking if the value we have is an instance of
WeakReference
. If it is, it means there was an entry in the identity
map, so we retrieve the model. If the retrieved model is null
,
it means that the garbage collector has already freed up the memory used by the model, so we need to tidy up
and remove this entry. We aren't using removeFromIdentityMap
, as
that requires an instance, which we don't have. We then return model
,
because it's either the model, or null
.
return null;
Finally, we return null
, because if this code is being executed, there
was no entry found in the identity map.
And The Rest?
Providing that you followed my code from the original article, specifically the bits where we consume our own
API, the rest of the code will work. The only method that retrieves models from the identity map directly,
is getFromIdentityMap
, with
existsInIdentityMap
and
newFromBuilder
making use of that method, so they inherit the changes.
What about Laravel Octane?
The changes above don't just help with memory usage in Laravel as it is by default, it also has the side-effect of limiting memory leakages when using Laravel Octane. However, if you have static properties elsewhere that reference a model, whether directly or through an array, collection, or other object, the leak will still occur (see the docs).
To get around this, you need to clear the identity map at the end of each request. There are a number of
ways you can achieve this, but the simplest is to use an event listener. The event you're listening for is
either Laravel\Octane\Events\RequestHandled
, or
Laravel\Octane\Events\RequestTerminated
. The former is fired immediately
after the controller (or other handler) has finished processing the request, while the latter is fired
during the shutdown/termination process, after the response has been sent. I recommend using
RequestTerminated
, as it allows for more wiggle room.
So go head and create yourself a listener, which can be done using the following command.
php artisan make:listener RequestTerminatedListener --event=\\Laravel\\Octane\\Events\\RequestTerminated
This will create a new listener class in the app/Listeners
directory,
which you can then update to match the following.
namespace App\Listeners;
use App\Model;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Laravel\Octane\Events\RequestTerminated;
class RequestTerminatedListener
{
/**
* Handle the event.
*/
public function handle(RequestTerminated $event): void
{
Model::clearIdentityMap();
}
}
I've removed the __construct
method, as it's not needed, and added a
call to Model::
inside
clearIdentityMap
handle
. Important thing to note here is that
Model
is the base model from the previous article, and since
clearIdentityMap
was defined as
static
, it can safely be called here.
What's next?
There are things you could do, like modify
removeFromIdentityMap
to accept a class and key,
instead of a model instance, or you could extract the identity map into its own class, so it works for non-model
objects. However, my aim was to show you, initially, how to create a minimal identity map, and then how to
avoid memory issues that can arise from it. You're free to experiment as you see fit, and if you'd like more
articles expanding on this, please let me know!
But for now, that's it on the topic of identity maps and Laravel. I hope you found this article useful.