Putting SOLID into practice in PHP: the Single Responsibility Principle
How I apply SRP — the S in SOLID — to PHP classes with concrete examples, moving beyond abstract theory.
The first time I came across the SOLID principles in a book, I thought “yeah, that makes sense” and moved on. Then they showed up again in a conference talk — still felt meaningful. But the moment it truly clicked was when I was staring at a bloated controller I had written myself: a single class that was querying the database, sending emails, and generating PDFs all at once.
The Single Responsibility Principle (SRP) is what the S in SOLID stands for. In short: a class should have only one reason to change.
What “single responsibility” does NOT mean
When I first heard it, I interpreted it as “a class should do only one thing.” That’s not wrong, but it’s incomplete. The more precise definition is: a class should be the concern of only one actor — one type of user, one stakeholder, one source of requirements.
In practice this means: whenever you need to change a class, the reason for that change should always come from the same source. If a single class has to satisfy both the email team and the accounting team, it already has two distinct reasons to change.
A problematic example
Consider a class that handles user registration:
<?php
class UserRegistration
{
public function register(array $data): void
{
// Validation
if (empty($data['email']) || empty($data['password'])) {
throw new \InvalidArgumentException('E-posta ve parola zorunludur.');
}
// Persist to database
$pdo = new \PDO('mysql:host=localhost;dbname=app', 'root', '');
$stmt = $pdo->prepare('INSERT INTO users (email, password) VALUES (?, ?)');
$stmt->execute([$data['email'], password_hash($data['password'], PASSWORD_BCRYPT)]);
// Send welcome email
mail($data['email'], 'Hoşgeldiniz', 'Kaydınız tamamlandı.');
}
}
This class bundles three distinct concerns: validation, persistence, and notification. Changing the password hashing algorithm, updating the email copy, or adding a new validation rule — every one of those changes touches this same class.
Separating the responsibilities
If we move each concern into its own class:
<?php
class UserValidator
{
public function validate(array $data): void
{
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Geçerli bir e-posta adresi giriniz.');
}
if (empty($data['password']) || strlen($data['password']) < 8) {
throw new \InvalidArgumentException('Parola en az 8 karakter olmalıdır.');
}
}
}
class UserRepository
{
private \PDO $pdo;
public function __construct(\PDO $pdo)
{
$this->pdo = $pdo;
}
public function save(string $email, string $password): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO users (email, password) VALUES (?, ?)'
);
$stmt->execute([$email, password_hash($password, PASSWORD_BCRYPT)]);
return (int) $this->pdo->lastInsertId();
}
}
class WelcomeMailer
{
public function send(string $email): void
{
mail($email, 'Hoşgeldiniz', 'Kaydınız tamamlandı.');
}
}
class UserRegistration
{
public function __construct(
private UserValidator $validator,
private UserRepository $repository,
private WelcomeMailer $mailer
) {}
public function register(array $data): int
{
$this->validator->validate($data);
$userId = $this->repository->save($data['email'], $data['password']);
$this->mailer->send($data['email']);
return $userId;
}
}
Now UserRegistration only coordinates the registration flow. If validation rules change, only UserValidator is updated. If the email-sending library is swapped out, only WelcomeMailer changes.
”Too many classes” — a common objection
The most frequent pushback I hear when showing this structure: “but now there are so many classes.” I get it — at first glance it feels overly fragmented. But experience has taught me that six months later, “find that validator class and add this rule” is far easier than hunting for the right line inside a three-hundred-line God Class.
There is no direct relationship between the number of classes and complexity. Complexity comes from how tightly coupled those classes are to each other — from how wide a surface area a single change ripples across.
Being able to write tests is the real indicator of SRP
Before I embraced SRP, every attempt at writing tests hit the same wall: the class was doing too many things, and setting up each test required a real database connection or a mail server. After separating responsibilities, I can test UserValidator in complete isolation — no database, no email. I just pass valid and invalid data and verify the behavior.
Noticing this connection matters: if testing a class in isolation becomes difficult, the class is probably carrying more than one responsibility. Test friction is an early warning sign of an SRP violation.
Where to stop
It is possible to take SRP too far. If you move every line into its own class, you end up with needless fragmentation. The practical rule I keep coming back to: if I have more than one independent reason to change a class, it needs to be split. If there is only one reason, I can leave it as-is.
For example, if I later want to extend WelcomeMailer to support HTML templates and multiple languages, and both of those needs come from the same actor (email notification responsibility), they can coexist in the same class. But if that class ever starts sending both marketing emails and transactional notifications, that is the signal it is time to split again.
I am planning to work through the rest of the SOLID principles as well. Moving an abstract principle from “oh yes, great idea” to “this is how I actually write code” turned out to take longer than I realized — without me even noticing it happening.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.