It's no big secret that I'm not the biggest fan of Eloquent, and anyone that has ever seen me talk about it, will have heard many of my reasons. One of these reasons is the way that it handles model instances, making no attempt to keep track of them, and instead relying on the developer to do so. Which is fine, but I don't think I've ever seen a project that actually does this properly.
The result is that you end up with a lot of duplicate instances of the same model, which can lead to all sorts
of problems. The biggest of these, is that if you have multiple paths within your code that have access to say
a User
model, and you update the name of that user in one place, it won't be reflected in the other
places. This is the exact sort of problem that the Identity Map pattern is designed to solve.
An Identity Map?
Identity maps are one of the patterns present in Martin Fowler's Patterns of Enterprise Application Architecture, and is defined as a registry that is responsible for mappings object instances to their identities. When used in this context, identities refers to a unique value contained within or derived from the data that the object contains, rather than the object itself.
The identity itself is arbitrary, and can be anything as long as it is unique to that family of objects, which typically means its class. Because this pattern is almost always used with objects that represent data from a database, the identity is usually that rows primary key.
There's also a catalog of the patterns present in Fowler's book, which includes a dedicated page for the identity map pattern.
Adding an identity map to Eloquent has been something that I've wanted to do for a long time, even going as far as starting work on it myself, back in Laravel 9. Something that I may pick back up at some point, though the implementation in that PR is more complex than the one I'm going to show you here. This one is about restricting the number of instances per row, and doesn't make any effort to prevent or limit queries.
What does this achieve?
Some of you may already know what will be achieved by adding an identity map to Eloquent, but some of you won't, so let's attempt to solve that.
As you go through this article, and go through the code that makes up the implementation of this minimal identity map, you may be inclined to think of it as a cache. This is not the case, and while it does, technically, cache objects, it isn't a cache in the same way that you would think of one.
The identity map exists to ensure that each row of data in the database has a unique representation in memory.
What this means is that if you query for a row, say five times, the query result for each will be the exact
same object instance, enough so that you can use the ===
(identicality) operator to compare them. This does also mean that if you change an attribute in one place,
it'll be reflected in all other places that reference that instance.
If you follow along with this article, and implement your own identity map, you'll end up with the following:
- Subsequent queries for the same row will return the same instance, rather than a new one, which includes the populating of relationships.
- Querying for a row after creating it will return the instance used when creating it.
- Deleting a model will remove it from the identity map, unless you're using soft-deletes, then it will only be removed with a force delete.
The absolute biggest takeaway from all of this, is that you no longer have to write code to account for the fact that a model instance you have, may not have the same data that was in the model instance that another part of the code had. I appreciate that this is a bit niche, but it is something that I've seen a lot of, and it can lead to some very odd bugs.
Adding the Identity Map
While not the only way to go about this, the simplest is to start with your own base model, which in turn
extends Laravels
Model
class.
namespace App;
use Illuminate\Database\Eloquent\Model as BaseModel;
abstract class Model extends BaseModel
{
}
Rather than create an entirely separate object to function as the identity map, we can simply use a static property on this base model.
private static array $identityMap = [];
This array will contain an array per model class, with each of those arrays containing the model instances mapped to their key. These arrays will in turn be mapped to the class name of the model. If you're familiar with generics or static analysis tools, the actual type of this property would be the following.
array<class-string<Model>, array<string|int, Model>>
Using Laravel Octane?
If you're using Laravel Octane, this property may cause
memory leaks
if not accounted for. Because the octane
workers are long-lived processes, static
properties will retain
their value for the entire life of the worker, which will span multiple requests.
To avoid this, you need to ensure that you're clearing the identity map after each request, otherwise the worker will retain every model instance it encounters.
Manipulating the Identity Map
With the base model, and the identity map array in place, we'll need some methods to manipulate it. Specifically, adding an instance, removing an instance, and clearing all for a model class.
Clearing the Identity Map
When clearing the identity map, the method should accept a possible class name, and if one isn't provided, it
should use the current class, through static::class
.
public static function clearIdentityMap(?string $class = null): void
{
self::$identityMap[$class ?? static::class] = [];
}
Pay special attention to the usage of self
and
static
in this code snippet, it's
important. If you called this method using
User::clearIdentityMap
, it would access the
identityMap
property on Model
,
but use App\Models\User
as the key.
This is intentional as it means all models are stored within the
Model
class, and it avoids possible unsafe static access.
Adding to the Identity Map
There are a handful of different ways to write the method to add a model instance to the identity map, and there's not much in it, though the following is the simplest and safest solution.
public static function addToIdentityMap(self $model): void
{
self::$identityMap[$model::class][$model->getKey()] = $model;
}
The self
type hint is important here, as it ensures that the provided
model is extending this new base model (either directly or indirectly), which we will use as an opt-in to the
identity map. Retrieving the model class using
model::class
over
static::class
is a matter of safety, and it prevents issues where the
provided instance is a different class to the one the method is called on.
If you'd prefer to use static::class
, I urge you to add a check to
compare the class name of the provided instance to the class name of the calling class, and throw an exception
if they don't match.
This method also makes the possible naive assumption that the model instance being passed is one that has been persisted to the database, and has a primary key. I absolutely appreciate that this is not always the case, so if you want to be more defensive, you could add a check to ensure that the model has a primary key, and throw an exception if it doesn't. Like so.
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()] = $model;
}
Removing an Instance from the Identity Map
Removing an instance from the identity map is as simple as unsetting the array entry, which won't error out if the key doesn't exist, so there's no need to check for its presence.
public static function removeFromIdentityMap(self $model): void
{
unset(self::$identityMap[$model::class][$model->getKey()]);
}
Accessing the Identity Map
We can now manipulate our identity map, but we can't access it, so we'll need methods for that. The first of
these methods will be a simple getter, which will return an instance or
null
, as well as a presence checker to see if an identity is mapped.
Retrieving an Instance from the Identity Map
Just like clearIdentityMap
, the method must also
accept a class name, and if one isn't provided, it should use the current class, through
static::class
.
public static function getFromIdentityMap(int|string $key, ?string $class = null): ?static
{
return self::$identityMap[$class ?? static::class][$key] ?? null;
}
Checking if a Key is Mapped
When checking to see if an instance is mapped to a key, we can internally make use of the
getFromIdentityMap
method, to ensure that the
retrieval logic is consistent. Also known as dog fooding.
public static function existsInIdentityMap(int|string $key, ?string $class = null): bool
{
return self::getFromIdentityMap($key, $class) !== null;
}
Automating the Identity Map
We now have a base model, the identity map array, methods to manipulate it, and methods to access it. However, the only way to actually use it, currently, is to manually call the methods which sort of defeats the purpose of it.
As this implementation is focusing on limiting the number of instances for a database row to one, rather than doing anything with queries, we need to hook into the Eloquent lifecycle in the appropriate places. Fortunately, there are only three places where it makes sense to do this.
Handling Query Results
The first of these is when a new model instance is created as the result of a query. Luckily, Eloquent has a
method called
newFromBuilder
that handles this. The
simplest way to hook into this, is to override it.
public function newFromBuilder($attributes = [], $connection = null): static
{
$attributes = (array)$attributes;
$key = $attributes[$this->getKeyName()] ?? null;
if ($key !== null) {
$existing = static::getFromIdentityMap($key);
if ($existing !== null) {
$existing->setRawAttributes($attributes, true);
$existing->fireModelEvent('synced', false);
return $existing;
}
}
$model = parent::newFromBuilder($attributes, $connection);
if ($model->exists && $model->getKey()) {
static::addToIdentityMap($model);
}
return $model;
}
It may not be immediately apparent what each part of this method is doing, so let's break it down.
The first thing that's done is to cast the attributes
variable to an
array
, which is done because this variable will often be an instance
of stdClass
.
$attributes = (array)$attributes;
Next, we attempt to retrieve the key from the provided attributes. Since this is a non-static method, we know
that we have access to the underlying model, including the configuration around the primary key, so we can
safely call the
getKeyName
method. If the key isn't
present, we can use null
.
$key = $attributes[$this->getKeyName()] ?? null;
If the key isn't null
, we then need to retrieve the current instance
for the key, and if one was found, we need to handle that. In this case, I've opted to update the existing
instance with the retrieved attributes, and then fire a brand new event called synced
. Once this is
done, the existing instance is returned.
if ($key !== null) {
$existing = static::getFromIdentityMap($key);
if ($existing !== null) {
$existing->setRawAttributes($attributes, true);
$existing->fireModelEvent('synced', false);
return $existing;
}
}
The
setRawAttributes
method is
extremely important, as it updates the attributes without triggering Eloquents internal
handling of attribute changes. Using something like
fill
instead, would cause the model to
appear dirty, as if the attributes had changed, and it would think it needs to be saved.
Please also be aware that the usage of this method will overwrite any changes on the model, that haven't yet been saved to the database.
The synced
event isn't important at all, it's just something that I added as it opens up a lot of
possibility without much of a cost, so you could drop it if you wanted. It should also be noted that you don't
have to update the existing instance, but I'll leave that decision up to you.
Finally, if the key was null
, or no existing instance was found, we'll
need to create a new one using the parent
newFromBuilder
method. Once we have that, we can
add it to the identity map, if we're confident that it both exists and has a primary key, before returning the
instance.
$model = parent::newFromBuilder($attributes, $connection);
if ($model->exists && $model->getKey()) {
static::addToIdentityMap($model);
}
return $model;
There are two reasons I decided to perform these checks here. The first is that as suggested above, the
addToIdentityMap
method will throw an exception if
this criteria isn't met, and the second is that the
getKey
method may have overridden logic, and it's
better to be safe than sorry.
Handling Freshly Created Models
The second place within the Eloquent lifecycle, is when a model has been persisted to the database, when it's freshly created. Eloquent doesn't have a method specifically for this, but it does have an observable event, which we can hook into once the model has booted.
If I went into the details of an Eloquent models boot process
this article would be much longer, but suffice it to say, that all we need to know is that they have a boot
process, and a static
booted
method that allows us
to do something at the end of this process.
protected static function booted(): void
{
parent::booted();
}
By default, the base method within
Model
doesn't
actually do anything, but it's worth getting into the habit of calling parent methods when overriding Eloquent
methods, as it can get a bit messy.
When a model is first saved to the database there are four internal events that are fired,
creating
, created
, saving
and saved
. The
saving
and saved
events are also fired during updates, so they aren't of use to us
here, and the creating
event is fired before the model is persisted, so it's unlikely to have a
key, and definitely won't be marked as existing. Which leaves us with created
, which is fired
after a model is first saved to the database.
Each of these events has a corresponding static method on every model, that allows you to easily register
event handlers. So the method we need is the
created
one, which we can call in the new booted
method,
adding the newly persisted model to the identity map.
protected static function booted(): void
{
parent::booted();
static::created(static function (self $model) {
if ($model->exists && $model->getKey()) {
static::addToIdentityMap($model);
}
});
}
Now, the created
event is fired a couple of lines after
Model::exists
is set to true
, however, we don't know what other events have
acted before ours, so it's worth double checking this.
The
getKey
check is because we need the model to have a key for it to be persisted.
Handling Deleted Models
The third and final place to hook into the Eloquent lifecycle, is when a model is deleted. This is a little different to the previous two, as we don't want to add the model to the identity map, but rather remove it. However, the process is almost identical to the one we just went through.
Just like when a model is first saved, deleting a model has its own events. By default, they all have
deleting
and deleted
, but those that are soft-deletable also have
trashed
, forceDeleting
and forceDeleted
. How you go about handling
model deletions within the identity map depends on whether the model can be soft deleted or not, so I'll split
this part into two sections.
Handling Deleted Models Without Soft Deletes
If the model doesn't have soft deletes, then we can simply hook into the
deleting
event, using the
deleting
method, and remove the model from the
identity map. We're using the deleting
event here, because
Model::exists
is set to false
, right before the deleted
event
is fired.
Luckily, we can simply add a call to the method inside our already created
booted
method.
protected static function booted(): void
{
parent::booted();
static::created(static function (self $model) {
if ($model->exists && $model->getKey()) {
static::addToIdentityMap($model);
}
});
static::deleting(static function (self $model) {
if ($model->exists && $model->getKey()) {
static::removeFromIdentityMap($model);
}
});
}
Handling Deleted Models With Soft Deletes
When a model is being soft-deleted, the deleting
event is fired during both a soft-delete,
and a hard-delete, which makes this event unreliable. We also don't want to remove the model from the
identity map if its only been soft-deleted, as it still exists and can easily be queried for.
If we only plan to support soft-deletes, then we can simply take the code from the previous section, and
instead use the
forceDeleting
method.
static::forceDeleting(static function (self $model) {
if ($model->exists && $model->getKey()) {
static::removeFromIdentityMap($model);
}
});
Your IDE is most likely going to complain that the method doesn't exist, and since you're only supporting
soft-deletes in this scenario, you'll want to add the
\Illuminate\Database\Eloquent\SoftDeletes
trait to your base model.
Now, let's say instead that we need to support both types of deletion. For this, we'll have to modify the
existing use of the
deleting
method.
Within this method, we need to determine whether the mode is being hard-deleted or soft-deleted, only removing
the instance from the identity map for the former.
static::deleting(static function (self $model) {
if ($model->exists && $model->getKey()) {
if (! method_exists($this, 'isForceDeleting') || $this->isForceDeleting()) {
static::removeFromIdentityMap($model);
}
}
});
Now, I'm not particularly fond of this solution, as it's a bit of a hack, but it ticks the most boxes. Models
that can be soft-deleted will use a trait, but no interface. There are ways to check that a model uses a trait,
but your IDE won't be able to understand it, and it will complain about the method not existing. This approach
does also have the very real risk, that if someone adds a
isForceDeleting
method to a mode that does not
support soft-deletes, for whatever reason, you'll encounter some odd behaviour.
The Final Piece
If we put everything together, we end up with the following base model class.
namespace App;
use Illuminate\Database\Eloquent\Model as BaseModel;
abstract class Model extends BaseModel
{
private static array $identityMap = [];
public static function clearIdentityMap(?string $class = null): void
{
self::$identityMap[$class ?? static::class] = [];
}
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()] = $model;
}
public static function removeFromIdentityMap(self $model): void
{
unset(self::$identityMap[$model::class][$model->getKey()]);
}
public static function getFromIdentityMap(int|string $key, ?string $class = null): ?static
{
return self::$identityMap[$class ?? static::class][$key] ?? null;
}
public static function existsInIdentityMap(int|string $key, ?string $class = null): bool
{
return self::getFromIdentityMap($key, $class) !== null;
}
public function newFromBuilder($attributes = [], $connection = null): static
{
$attributes = (array)$attributes;
$key = $attributes[$this->getKeyName()] ?? null;
if ($key !== null) {
$existing = static::getFromIdentityMap($key);
if ($existing !== null) {
$existing->setRawAttributes($attributes, true);
$existing->fireModelEvent('synced', false);
return $existing;
}
}
$model = parent::newFromBuilder($attributes, $connection);
if ($model->exists && $model->getKey()) {
static::addToIdentityMap($model);
}
return $model;
}
protected static function booted(): void
{
parent::booted();
static::created(static function (self $model) {
if ($model->exists && $model->getKey()) {
static::addToIdentityMap($model);
}
});
static::deleting(static function (self $model) {
if ($model->exists && $model->getKey()) {
if (! method_exists($this, 'isForceDeleting') || $this->isForceDeleting()) {
static::removeFromIdentityMap($model);
}
}
});
}
}
If you've followed along with this, or simply used the final class (or the gist), you'll have a base model class with a fully functioning minimal identity map. All you need to do now, is remember to extend it.
If you use the artisan make:model
command, you can use the
artisan stub:publish
command, and then modify the model stub
to use your new base model, which you can do by removing the import for
Illuminate\Database\Eloquent\Model
.
There are possible memory issues that can arise from using an identity map, specifically when using this implementation. So, there's a follow-up article that goes into more detail about it, and how you can fix/avoid it.
June 10th 2025
Managing the Memory Usage of the Laravel Eloquent Identity Map
In my previous article, I introduced a minimal identity map for Laravel Eloquent. In this follow-up, I'll show you how to better manage the memory usage of the identity map, and prevent memory leaking issues for particularly heavy processes, including Laravel Octane.