In programming, there are many different approaches to structuring code. For example, the procedural style—familiar from school or university—where a program is described as a series of sequential instructions:
step1($data);
step2($data);
step3($data);
This programming style is easy to grasp and was the main approach for many years in languages such as C, Pascal, and others.
However, in modern PHP and frameworks like Laravel, the object-oriented approach is commonly used. In this paradigm, we create classes, instantiate them, and use methods to manage the behavior of objects. OOP is based on the idea that data and behavior should be encapsulated within a single “living” object. For example, consider the App
class:
$app = new App();
$app->start();
In this case, we don't instruct the program step by step; instead, we delegate the complete initialization and execution to the object. This approach hides implementation details and provides a clean interface for interacting with the object.
Yet, there is a vast amount of code and practices in OOP languages that remain procedural in nature. Let’s explore a few examples.
Dumb Objects Lead to Procedural Code
There is a common practice where an object is used purely as a container for data—it contains no business logic, only data storage. Consider the following example:
class User
{
private string $name;
private string $email;
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): void
{
$this->email = $email;
}
}
Such a class is often mistakenly called a "Data Transfer Object" (DTO), but that's not accurate. A DTO should be immutable, and having setters violates that principle.
Here, the User
object is used solely for storing data. Later, this data is processed in various parts of the application. For instance:
// In one part of the code, an object is created and populated with data
$user = new User();
$user->setName('John Doe');
$user->setEmail('john.doe@example.com');
// In another part of the code, the data is validated
$validator = new Validator();
$validator->validate($user);
// In yet another part of the code, an email is sent to the user
$notification = new WelcomeNotification();
$notification->send($user->getEmail());
In this example, the User
object doesn't possess its own logic—external classes like Validator
and Mailer
handle data processing. Essentially, rather than using an array to store information, we use an object; however, the business logic is moved outside it. This results in an architecture that is closer to procedural programming, where the program consists of a set of functions acting upon data.
A similar situation is observed in ORMs that do not use the Active Record pattern, such as Doctrine:
$entityManager = EntityManager::create($connection, $config);
// Create a user object
$user = new User();
$user->setName("John Doe");
$user->setEmail("john.doe@example.com");
// Save to the database
$entityManager->persist($user);
$entityManager->flush();
This contradicts the principles of object-oriented programming because objects should be responsible for their own data and behavior.
As a result, we end up with a system where logic is distributed between “dumb” objects and procedural sections of code. This separation of responsibilities makes the application harder to understand, test, and further develop.
Tell, Don’t Ask
Tell, Don’t Ask is a well-articulated principle of quality object-oriented design that helps in creating a clean and maintainable architecture.
"Don't ask an object for data to make a decision—tell the object what to do."
At first glance, this principle might seem to contradict the Single Responsibility Principle (SRP), but in real-world development, you often have to choose which principle is more important in a given context. Still, the more principles you manage to adhere to simultaneously, the better.
Instead of creating a “dumb” object, you can encapsulate business logic directly within the object or in a related service. For example, if you need to validate data and send a welcome email, you can delegate this responsibility to an object or service that knows how to handle it. Consider the following two approaches:
Rather than extracting data and validating it externally, let the object perform the necessary action itself:
class User
{
protected string $name;
protected string $email;
private function validate(): bool
{
$validator = Validator::make([
'name' => $this->name,
'email' => $this->email,
], [
'name' => 'required',
'email' => 'required|email',
]);
return $validator->passes();
}
public function register(): void
{
$this->validate();
// Save the user to the database...
$this->notify(WelcomeNotification::class);
}
}
$user = new User(
name: "John Doe",
email: "john.doe@example.com"
);
$user->register();
Here, the object itself performs validation and sends notifications rather than simply exposing data for external manipulation.
Additionally, the Tell, Don’t Ask principle works well with the Law of Demeter (LoD), which also promotes the creation of more resilient and independent objects.
For further reading, you can explore works by Martin Fowler, Allen Holub, and Yegor Bugayenko.
Top comments (1)
Separation of concerns is not a bad thing.
You write objects, but you mean entities. Not all objects have the same task that it why they have different names.
The validation in the entity only makes sure that it is good for storage. This doesn't exclude data validations in other parts of the application.
There is a reason in Domain Driven Design infrastructure is separated from application and domains.
And that is why the Doctrine example is bad to demonstrate your point. It separates the execution of the database queries, infrastructure, from the data structure that is used by the application.
Entities should control their own lifecycle; creation, manipulation and deletion. How the application handles that lifecycle is not in the scope of the entity.
And that is what again what the Doctrine example shows. The
EntityManager
is responsible for the lifecycle with the persist and remove methods.There are all kinds of architectures, from hybrids to pure. But the last example is not what I want to see in a codebase.