Example code
<?php
class User
{
public int $id;
public string $name;
public string $email;
public function save() {
// Save the user object
}
public static function find($id) {
// Retrieve a user object by ID
}
}
// Usage example
$user = User::find(1); // Retrieve a user with ID 1
echo $user->name;
echo $user->email;
$user->name = 'Foo Bar';
$user->save();
Benefits
The pattern simplifies database operations by representing database tables as objects, making interaction and manipulation straightforward.
It is easy to grasp & beginner-friendly, and can be a helpful abstraction compared to using plain SQL.
For many MVC frameworks the M (Model) part is basically an implementation of Active Record, like Eloquent in Laravel.
Downsides
From my perspective, it totally makes sense to use ORMs for medium- to bigger-sized projects.
Often I'll try to avoid the Active Record pattern though because it basically violates two important principles:
Single responsibility principle
A class should have only one reason to change.
While it's often arguable what exactly should be considered a single reason or responsibility in software development, the active record pattern has without any doubt several responsibilities:
- reading data (usually there's a find() method)
- persisting data (usually there's a save() or update() method)
- representing a single data row (usually there are public properties or getters to access the values of each column)
Separation of concerns principle
A system should be divided into separate modules, each focusing on a specific aspect of functionality, to improve maintainability and modularity.
Every place in your code you pass the active record object to will have the ability to read and modify user rows in your database. In many cases, you probably only pass the object to different places because you need to work with the data fields (such as - for an User object: the username, registration date, status and so on).
Is it a bad thing?
Not necessarily. For smaller projects, it's a great way to achieve quick results. The pattern provides a clear and convenient way to work with your database.
And even for bigger projects, when used with care, the pattern can be quite pleasant to work with.
But there is a risk you should be aware of:
Usually, after you read out data from the database, you will pass these data to a lot of places in your code.
Like, if you read out a User from the database, you might pass it to some business class (like a NotificationSender or MailSender to send out notifications for that user) or to some template engine to display the user's data.
Now ask yourself the question: Should these places be able to modify the user row in your database?
While you might argue that a NotificationSender might still be able to modify the user row (for example, to update the lastNotificationSent date), this doesn't necessarily be true for every business class.
Most likely, you definitely don't want your template engine to be able to update the user row.
You could now argue that you can take the risk - but can you still take, if you work together with many other developers in the same team. Will everyone in your team know and follow the rules?
Another downside of the pattern is that testing could be much harder.
Let's assume you want to unit-test a business class that retrieves an Active Record Object:
<?php
class Sender
{
public function send(User $user): void
{
...
$user->sentAt = date('Y-m-d H:i:s');
$user->save();
}
}
...
$sender = new Sender();
$sender->send($user);
How would a unit test for Sender look like? Probably similar to this:
<?php
class SenderTest extends Test
{
private Sender $sender;
public function setUp(): void
{
parent::setUp();
$this->sender = new Sender();
}
public function testSend(): void
{
...
}
}
The problem here is, that - without any further ado - the test now saves the passed user in the real database. You are not easily able to mock the logic that does the actual persisting to the database. But - especially for a unit test - you should mock it. Because the unit to test here i the Sender class. Persisting user data is not the responsibility of the Sender itself.
Please mind: I don't say here that it's impossible to mock such logic. If you really want, I'm sure you'd find a way. But the question is how complex it will be and if it's actually worth it.
Alternatives
One alternative is to not use ORM at all and instead use plain SQL queries.
For sure there are many developers out there who think that the benefits of an ORM doesn't compensate for the additional complexity and abstractness.
For small projects like microservices that might be true and it's worth to consider this approach.
I've worked on many projects in larger teams where it made complete sense to have an ORM in place.
In these cases, I chose an ORM that has a more precise separation of responsibilities, which typically looks like this:
- a class to read out data from the database. i.e. a UserRepository which contains find*() methods.
- a class to insert and modify data in the database, i.e. a UserEntityManager which contains save() and update() methods.
- a class representing a data row, i.e. a UserDto class which has setters and getters for every value, but no business logic
With this approach, I can safely pass the DTO class to other places in my code and be sure that these places won't be able to read out more data from the database or modify its data.
Let's see how a Sender class could look like if we have an entity manager:
<?php
class Sender
{
public function __construct(
private readonly UserEntityManagerInterface $userEntityManager
) {}
public function send(UserDto $user): void
{
...
$user->setSentAt(date('Y-m-d H:i:s'));
$this->userEntityManager->save($user);
}
}
...
$entityManager = new UserEntityManager();
$sender = new Sender($entityManager);
$sender->send($user);
Nice side effect: By passing the Entity Manager to the constructor it's quite clear that the Sender class will perform database modifications for users. You'll know that even without looking into each method and each method's implementation of the class.
- every class that has the UserEntityManager injected, is able to modify user data.
- any class without the UserEntityManager injected, won't be able to modify user data.
- every class that has the UserRepository injected, is able to read user data.
- any class without the UserRepository injected, won't be able to read user data.
A test case could look like this:
<?php
class SenderTest extends Test
{
private Sender $sender;
/**
* @var UserEntityManagerInterface&MockObject
*/
private UserEntityManagerInterface $entityManager;
public function setUp(): void
{
parent::setUp();
$entityManager = $this->createMock(UserEntityManagerInterface::class);
$this->sender = new Sender($entityManager);
}
public function testSend(): void
{
...
}
}
You see: mocking the functionality to save a user is now quite straight-forward: No magic involved and easy to grasp.
Creating entity managers, repositories and DTOs for every entity might be a bit time-consuming and "boilerplatey"
in the beginning but there's a good chance it will pay off later on.