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:

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.

If you'd like to stay updated with my latest articles, tutorials, projects, and thoughts on everything Laravel and PHP, you can subscribe to my newsletter.

Got thoughts, questions, or just want to chat? I'm always up for a good conversation— get in touch, or find me on BlueSky, X or Discord

Class
Model
Namespace
Illuminate\Database\Eloquent
Description

Laravel's base model class, extended by Eloquent models, whether directly or indirectly.

Method
newFromBuilder()
Class
Illuminate\Database\Eloquent\Model
Parameters
array|stdClass $attributes - The attributes to assign.
string|null $connection - The name of the connection used.
Description

Creates a new instance of a model from an array of attributes, optionally with the connection name. The method is used to create new instances from query results through the Eloquent query builder, but is not limited to that use.

Links
Method
getKeyName()
Class
Illuminate\Database\Eloquent\Model
Description

Returns the name of the primary key attribute for the model. It makes use of the primaryKey property on all models, which defaults to the value id.

Links
Method
setRawAttributes()
Trait
Illuminate\Database\Eloquent\Concerns\HasAttributes
Inherited By
Illuminate\Database\Eloquent\Model
Parameters
array|stdClass $attributes - The attributes to assign.
bool $sync - Whether to sync the attributes as the original. Defaults to true.
Description

Sets the attributes for the model, without performing any checks. It also immediately syncs the provided values as the original attributes, if $sync is true, so that the model does not appear dirty.

Links
Method
fill()
Class
Illuminate\Database\Eloquent\Model
Parameters
array $attributes - The attributes to mass assign.
Description

Populates the attributes of the model using mass-assignment.

Method
getKey()
Class
Illuminate\Database\Eloquent\Model
Description

Returns the value of the primary key attribute for the model. It makes use of the getKeyName and getAttribute methods.

Links
Method
booted()
Class
Illuminate\Database\Eloquent\Model
Description

A protected static method provided by Laravels base Model class, which allows developers to perform actions once the model has been booted.

Links
Method
created()
Trait
Illuminate\Database\Eloquent\Concerns\HasEvent
Inherited By
Illuminate\Database\Eloquent\Model
Parameters
\Illuminate\Events\QueuedClosure|callable|array|class-string $callback - The callback to be called for the event.
Description

A public static method provided by Laravels base Model class, which serves as a helper to register an event handler for the Eloquent created event.

The event is triggered after a model has been saved to the database for the first time.

Property
exists
Class
Illuminate\Database\Eloquent\Model
Description

A public property on Laravels base Model class, which when true, denotes a model as existing in the database, and when false, denotes a model as not existing in the database.

Links
Method
deleting()
Trait
Illuminate\Database\Eloquent\Concerns\HasEvent
Inherited By
Illuminate\Database\Eloquent\Model
Parameters
\Illuminate\Events\QueuedClosure|callable|array|class-string $callback - The callback to be called for the event.
Description

A public static method provided by Laravels base Model class, which serves as a helper to register an event handler for the Eloquent deleting event.

The event is triggered before a model is deleted from the database.

Method
forceDeleting()
Trait
Illuminate\Database\Eloquent\SoftDeletes
Inherited By
Illuminate\Database\Eloquent\Model
Parameters
\Illuminate\Events\QueuedClosure|callable|array|class-string $callback - The callback to be called for the event.
Description

A public static method provided by Laravels base Model class, which serves as a helper to register an event handler for the Eloquent forceDeleting event. This event is only present for soft-deletable models.

The event is triggered before a soft-deletable model is permanently deleted from the database.

Method
isForceDeleting()
Trait
Illuminate\Database\Eloquent\SoftDeletes
Inherited By
Illuminate\Database\Eloquent\Model
Description

Returns whether the model is currently being force deleted or not.