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 Illuminate\Database\Eloquent\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 App\Model
, but use App\Models\User
as the key.
This is intentional as it means all models are stored within the App\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 App\Model::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 App\Model::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 Illuminate\Database\Eloquent\Model::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
Illuminate\Database\Eloquent\Model::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 Illuminate\Database\Eloquent\Model::setRawAttributes()
method is extremely important, as it updates the attributes without triggering Eloquents
internal handling of attribute changes. Using something like Illuminate\Database\Eloquent\Model::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
Illuminate\Database\Eloquent\Model::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
App\Model::addToIdentityMap()
method will throw an exception if this criteria isn't met, and the second is that the Illuminate\Database\Eloquent\Model::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 Illuminate\Database\Eloquent\Model::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 Illuminate\Database\Eloquent\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 Illuminate\Database\Eloquent\Model::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 Illuminate\Database\Eloquent\Model::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 Illuminate\Database\Eloquent\Model::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 Illuminate\Database\Eloquent\Model::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
Illuminate\Database\Eloquent\Model::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 Illuminate\Database\Eloquent\Model::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 Illuminate\Database\Eloquent\Model::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.