Introduction
Laravel’s Eloquent makes it feel almost magical to move data in and out of your database. Under the hood, that magic is powered by the Active Record pattern, that's a straightforward way to treat a database row as a first-class PHP object with built-in CRUD behaviour. In this article I'll explain what the Active Record pattern is, its pros and cons and we will build a tiny implementation of it in plain PHP for you to understand better the mechanics behind Eloquent.
What is the Active Record Pattern
The Active Record pattern treats each database row as an object, and that object knows how to save, update, and delete itself. In other words, the data (the row’s columns) and the persistence behaviour (CRUD) live together on the same class.
In Eloquent, a model like Post
maps to the posts
table. You read and write in database through the model itself and relationships are expressed as methods that return query builders (hasMany
, belongsTo
, etc.), so you can compose queries in a fluent way. Here are some of the reasons why Eloquent is so popular.
- It’s straightforward: The model you work with is the same thing you persist. No extra layers.
-
It’s discoverable: Need to update a post? Look at the
Post
model. Need comments for a post? Call$post->comments
. - It’s conventional: Having conventions for primary keys, timestamps, and relationships reduce boilerplate and decision fatigue.
Pros
These are some of the pros of the Active Record pattern:
- Fast productivity: Minimal ceremony for CRUD; get features shipping quickly.
-
Readable queries: Intent is clear
Post::query()->published()->with('comments')->get();
. - Clear ownership: Logic about a row lives on the model that represents it.
- Easy bootstrapping: Conventions for tables, keys, timestamps and others, lower the barrier to entry.
Cons
There's nothing in software development that doesn't have cons, here are some cons of the Active Record pattern:
- Coupled to the database: Business logic can become persistence-aware, making boundaries blur in complex domains.
- Fat models: As rules grow, models accumulate validation, queries, events, and domain behaviour, becoming hard to maintain.
- Testing friction: True unit tests are trickier. Many tests hit the database or need heavy mocking/fakes.
- Portability limits: Code tightly bound to an Active Record implementation can be harder to move to other patterns later.
Mitigating the cons
As we saw above, and with everything else in software development, everything has its pros and cons. Below I'll share some tips to mitigate the cons when working with an Active Record implementation like Eloquent:
- Keep models lean: Push orchestration and multi-entity rules into Actions.
- Encapsulate queries: Use query scopes and dedicated query classes for complex reads.
- Test with intent: Prefer feature tests for persistence flows and isolate pure domain logic where possible.
Implementing the Active Record Pattern
At its core, an Active Record implementation does four things:
- Maps a class to a table and an object to a row.
- Hydrates objects from query results (attributes array).
- Tracks changes (dirty checking) to decide between INSERT and UPDATE.
- Exposes persistence methods (create, save, delete) directly on the model.
Now we are going to create a tiny and simple implementation of the Active Record pattern without using any frameworks or libraries, only plain PHP. With this, you'll understand better the mechanics behind Eloquent.
Initial setup
The first thing we're going to do is to create a simple class to handle the database connection:
<?php
declare(strict_types=1);
final class Database
{
public static function pdo(): \PDO
{
static $pdo = null;
if ($pdo === null) {
$pdo = new \PDO(
'mysql:host=127.0.0.1;port=3306;dbname=app;charset=utf8mb4',
'user',
'pass',
[
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
return $pdo;
}
}
With our Database
class implemented, we can start the implementation of the Active Record pattern. In this first phase, we're going to define the logic needed for having something similar with an Eloquent Model, but without adding any logic related to the database yet:
<?php
declare(strict_types=1);
abstract class ActiveRecord
{
protected static string $table;
protected static string $primaryKey = 'id';
// Whitelist of mass-assignable columns
protected array $fillable = [];
// Auto-manage created_at / updated_at if present
protected bool $timestamps = true;
// Current and original state for dirty checking
protected array $attributes = [];
protected array $original = [];
public function __construct(array $attributes = [])
{
$this->fill($attributes);
$this->original = $this->attributes;
}
public function __get(string $name): mixed
{
return $this->attributes[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
if (\in_array($name, $this->fillable, true) || $name === static::$primaryKey) {
$this->attributes[$name] = $value;
}
}
public function fill(array $attributes): void
{
foreach ($attributes as $key => $value) {
if (\in_array($key, $this->fillable, true)) {
$this->attributes[$key] = $value;
}
}
}
}
Some things here may be familiar to you like the $table
, $primaryKey
, $fillable
and $timestamps
properties. You probably use them a lot when handling with Eloquent Models. But the magic starts happening with the other two properties: $attributes
and $original
. You can see that when calling the constructor, it fills these two properties. First, it checks if the keys passed are fillable by checking the $fillable
property and if they are, they are set in the $attributes
property. Then it makes the $original
to have the same content as the $attributes
. This is important because this is how our model will know if something was changed or not.
When working with Eloquent you maybe wondered how it knows how to map the properties you want to access if you don't define the properties in the model itself:
$post->title = 'Laravel Eloquent';
$post->title; // Laravel Eloquent
This is possible due to the usage of the magic methods: __set
and __get
. As you can see when you call $post->title = 'Laravel Eloquent';
, since the object doesn't have this property defined on it, it calls the magic method __set
that in this case will check if the property you want to set is fillable, and if so, it will add the value to the $attributes
property. When you call $post->title;
happens something similar, since the object doesn't have this property defined on it, it calls the magic method __get
that in this case will try to get the value from the $attributes
property.
Implementing the reading logic
Now we can start our implementation to connect our models to the database. Let's start with a simple example on how to retrieve all the records from database for a model, and how to retrieve a single model by its primary key. So let's add these two methods to our ActiveRecord
class.
public static function all(): array
{
$pdo = Database::pdo();
$table = static::$table;
$stmt = $pdo->query("SELECT * FROM {$table}");
$rows = $stmt->fetchAll() ?: [];
return array_map(function (array $row) {
$model = new static();
// Hydrate all columns, not only fillable
$model->attributes = $row;
$model->original = $row;
return $model;
}, $rows);
}
public static function find(int $id): ?static
{
$pdo = Database::pdo();
$table = static::$table;
$pk = static::$primaryKey;
$stmt = $pdo->prepare("SELECT * FROM {$table} WHERE {$pk} = :id LIMIT 1");
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
$model = new static();
// Hydrate all columns, not only fillable
$model->attributes = $row;
$model->original = $row;
return $model;
}
As you can see, in the all
method, we're using our Database
class to connect to the database and we're just running a dead simple query to get all the data from the model table and then we're mapping each row from the result of the query into an instance of the model and filling both the $attributes
and the $original
properties. The same happens with the find
method, but in this case we get a single record from the database using the primary key as a filter. One thing to note here is that when getting the data from the database, we don't want to fill only the fillable values, we want to fill all the data that's in the database, that's why we don't check for the fillable properties.
Implementing the saving logic
Now that we have how to get records from the database with our implementation, it's time to implement the logic for creating and updating records. This will be the most complex logic of our implementation, but it's still much simpler from what Eloquent does. For that we will add some more methods to our ActiveRecord
class.
public function save(): void
{
$pk = static::$primaryKey;
$isNew = empty($this->attributes[$pk]);
if ($this->timestamps) {
$now = $this->now();
$this->attributes['updated_at'] = $now;
if ($isNew) {
$this->attributes['created_at'] = $now;
}
}
$isNew ? $this->insert() : $this->updateRow();
// Sync original after successful persistence
$this->original = $this->attributes;
}
protected function insert(): void
{
$pdo = Database::pdo();
$table = static::$table;
$pk = static::$primaryKey;
// Insert only fillable columns (and any explicitly set primary key)
$insertable = array_values(array_unique(array_merge($this->fillable, [$pk])));
$data = array_intersect_key($this->attributes, array_flip($insertable));
unset($data[$pk]); // let DB autogenerate if auto-increment
if ($data === []) {
throw new \RuntimeException('No attributes to insert.');
}
$columns = array_keys($data);
$placeholders = array_map(fn($c) => ':' . $c, $columns);
$sql = sprintf(
'INSERT INTO %s (%s) VALUES (%s)',
$table,
implode(', ', $columns),
implode(', ', $placeholders)
);
$stmt = $pdo->prepare($sql);
$stmt->execute($data);
// If PK is auto-increment, capture it
if (empty($this->attributes[$pk])) {
$this->attributes[$pk] = (int) $pdo->lastInsertId();
}
}
protected function updateRow(): void
{
$pdo = Database::pdo();
$table = static::$table;
$pk = static::$primaryKey;
$changes = $this->changedAttributes();
// Only persist fillable changes (never overwrite PK here)
$changes = array_intersect_key($changes, array_flip($this->fillable));
if ($changes === []) {
return; // nothing to do
}
$sets = [];
$params = [];
foreach ($changes as $col => $val) {
$sets[] = "{$col} = :{$col}";
$params[$col] = $val;
}
$params['__id'] = $this->attributes[$pk];
$sql = sprintf(
'UPDATE %s SET %s WHERE %s = :__id',
$table,
implode(', ', $sets),
$pk
);
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
}
protected function changedAttributes(): array
{
$changes = [];
foreach ($this->attributes as $key => $value) {
$orig = $this->original[$key] ?? null;
if ($value !== $orig) {
$changes[$key] = $value;
}
}
return $changes;
}
protected function now(): string
{
return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
}
Here we have a lot of things going on, so let's break down in parts.
The save
method is the entrypoint for our saving logic. This method is responsible for:
- Checking if the the model is a new one by inspecting if the primary key is set in the
$attributes
property. - Adding the
created_at
andupdated_at
to the$attributes
if the$timestamps
is set totrue
. For this it uses thenow
helper method, that basically creates a new immutable object using the UTC timezone. - Call the correct action for the database: CREATE or UPDATE.
- Hydrate the
$original
property with the data that was saved in the database.
The insert
method is the one responsible for the INSERT
action in the database. This method is responsible for:
- Making sure that only fillable columns are going to be inserted in the database.
- Running the
INSERT
statement in the database. - Getting the auto-generated primary key from the database and adding it to the model
$attributes
property.
The updateRow
method is the one responsible for the UPDATE
action in the database. This method is responsible for:
- Making sure that only fillable columns that were changed are going to be updated in the database. For this it uses the
changedAttributes
helper method, that builds a list of the values that were changed by comparing the$attributes
property against the$original
property. - If no changes are found between the current state of the model -
$attributes
- and the database state of the model -$original
, no database actions are performed. - Running the
UPDATE
statement in the database.
Implementing the deletion logic
Now we can read and write from the database with our Active Record implementation. The only thing missing is to be able to delete a record from the database and this will be a pretty simple and straightforward implementation.
public function delete(): void
{
$pk = static::$primaryKey;
if (empty($this->attributes[$pk])) {
return;
}
$pdo = Database::pdo();
$table = static::$table;
$stmt = $pdo->prepare("DELETE FROM {$table} WHERE {$pk} = :id");
$stmt->execute(['id' => $this->attributes[$pk]]);
}
As you can see, this method checks if the primary key is set in the $attributes
, and if not, it does nothing. If the primary key is set it then builds and runs a DELETE
statement in the database using the primary key.
Using our Active Record Implementation
We just finished creating a tiny and simple implementation of the Active Record pattern above, here's the full code for our ActiveRecord
class.
<?php
declare(strict_types=1);
abstract class ActiveRecord
{
protected static string $table;
protected static string $primaryKey = 'id';
// Whitelist of mass-assignable columns
protected array $fillable = [];
// Auto-manage created_at / updated_at if present
protected bool $timestamps = true;
// Current and original state for dirty checking
protected array $attributes = [];
protected array $original = [];
public function __construct(array $attributes = [])
{
$this->fill($attributes);
$this->original = $this->attributes;
}
public function __get(string $name): mixed
{
return $this->attributes[$name] ?? null;
}
public function __set(string $name, mixed $value): void
{
if (\in_array($name, $this->fillable, true) || $name === static::$primaryKey) {
$this->attributes[$name] = $value;
}
}
public function fill(array $attributes): void
{
foreach ($attributes as $key => $value) {
if (\in_array($key, $this->fillable, true)) {
$this->attributes[$key] = $value;
}
}
}
public static function all(): array
{
$pdo = Database::pdo();
$table = static::$table;
$stmt = $pdo->query("SELECT * FROM {$table}");
$rows = $stmt->fetchAll() ?: [];
return array_map(function (array $row) {
$model = new static();
// Hydrate all columns, not only fillable
$model->attributes = $row;
$model->original = $row;
return $model;
}, $rows);
}
public static function find(int $id): ?static
{
$pdo = Database::pdo();
$table = static::$table;
$pk = static::$primaryKey;
$stmt = $pdo->prepare("SELECT * FROM {$table} WHERE {$pk} = :id LIMIT 1");
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
$model = new static();
// Hydrate all columns, not only fillable
$model->attributes = $row;
$model->original = $row;
return $model;
}
public function save(): void
{
$pk = static::$primaryKey;
$isNew = empty($this->attributes[$pk]);
if ($this->timestamps) {
$now = $this->now();
$this->attributes['updated_at'] = $now;
if ($isNew) {
$this->attributes['created_at'] = $now;
}
}
$isNew ? $this->insert() : $this->updateRow();
// Sync original after successful persistence
$this->original = $this->attributes;
}
public function delete(): void
{
$pk = static::$primaryKey;
if (empty($this->attributes[$pk])) {
return;
}
$pdo = Database::pdo();
$table = static::$table;
$stmt = $pdo->prepare("DELETE FROM {$table} WHERE {$pk} = :id");
$stmt->execute(['id' => $this->attributes[$pk]]);
}
protected function insert(): void
{
$pdo = Database::pdo();
$table = static::$table;
$pk = static::$primaryKey;
// Insert only fillable columns (and any explicitly set primary key)
$insertable = array_values(array_unique(array_merge($this->fillable, [$pk])));
$data = array_intersect_key($this->attributes, array_flip($insertable));
unset($data[$pk]); // let DB autogenerate if auto-increment
if ($data === []) {
throw new \RuntimeException('No attributes to insert.');
}
$columns = array_keys($data);
$placeholders = array_map(fn($c) => ':' . $c, $columns);
$sql = sprintf(
'INSERT INTO %s (%s) VALUES (%s)',
$table,
implode(', ', $columns),
implode(', ', $placeholders)
);
$stmt = $pdo->prepare($sql);
$stmt->execute($data);
// If PK is auto-increment, capture it
if (empty($this->attributes[$pk])) {
$this->attributes[$pk] = (int) $pdo->lastInsertId();
}
}
protected function updateRow(): void
{
$pdo = Database::pdo();
$table = static::$table;
$pk = static::$primaryKey;
$changes = $this->changedAttributes();
// Only persist fillable changes (never overwrite PK here)
$changes = array_intersect_key($changes, array_flip($this->fillable));
if ($changes === []) {
return; // nothing to do
}
$sets = [];
$params = [];
foreach ($changes as $col => $val) {
$sets[] = "{$col} = :{$col}";
$params[$col] = $val;
}
$params['__id'] = $this->attributes[$pk];
$sql = sprintf(
'UPDATE %s SET %s WHERE %s = :__id',
$table,
implode(', ', $sets),
$pk
);
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
}
protected function changedAttributes(): array
{
$changes = [];
foreach ($this->attributes as $key => $value) {
$orig = $this->original[$key] ?? null;
if ($value !== $orig) {
$changes[$key] = $value;
}
}
return $changes;
}
protected function now(): string
{
return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
}
}
Now we can now create our first model to use this implementation.
<?php
declare(strict_types=1);
final class Post extends ActiveRecord
{
protected static string $table = 'posts';
protected array $fillable = [
'title',
'content',
'published_at',
];
}
And now we can finally use our model.
<?php
declare(strict_types=1);
// Create a post
$post = new Post([
'title' => 'Hello Active Record',
'content' => 'A small example in plain PHP.',
'published_at' => null,
]);
// Read it back
$found = Post::find($post->id);
// Update
$found->title = 'The Active Record Pattern';
$found->save();
// Delete
$found->delete();
This example is clearly not production-grade (no validation, no query builder, no relationships, no guards against SQL injection beyond prepared statements, no concurrency control), but it shows the essential mechanics behind Eloquent’s magic. Once you understand these moving parts, the “magic” in Laravel feels more natural.
Conclusion
Eloquent’s power comes from the Active Record pattern: models that are both your data and the way you persist that data. In this article, we clarified what Active Record is, learned about its trade-offs, and demystified it with a tiny implementation in plain PHP. You can now understand better the mechanics and magic behind Eloquent.
I hope that you liked this article and if you do, don’t forget to share this article with your friends!!! See ya!