- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Database Playbook: Choosing the Right Store for Every System You Build
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open any long-running Laravel or Symfony codebase. Search for the word Repository. Pick the first hit your editor opens. Odds are good that the file has a method called getUsersAsArray(), a parameter typed array $filters, and a return type of array populated with stdClass rows. It lives in AppRepositories, is registered in the container, and gets called “the user repository” in code review.
It is not a repository. It is a DAO wearing a Repository sticker.
That mislabel is the single most common architectural lie in PHP. It looks harmless because the class works — rows come out, rows go in, tests pass against a fixture. The damage shows up later. You try to swap a database. A use case has to import three vendor types to ask a business question. A new hire reads findUsersBy(['status' => 'active', 'order_by' => 'created_at DESC', 'limit' => 10]) and asks where the domain went. The answer is that it never got written down. The schema is the domain.
This post draws the line in PHP 8.3 terms: a real Repository, a DAO, and the test that separates them.
A DAO that calls itself a Repository
Here is the file you have already seen, in some form, in every project you have inherited.
declare(strict_types=1);
namespace AppRepositories;
use IlluminateSupportFacadesDB;
final class UserRepository
{
/** @return array */
public function findAll(array $filters = [], int $limit = 50, int $offset = 0): array
{
$query = DB::table('users');
if (isset($filters['status'])) {
$query->where('status', $filters['status']);
}
if (isset($filters['email_like'])) {
$query->where('email', 'like', '%' . $filters['email_like'] . '%');
}
return $query
->orderBy('created_at', 'desc')
->limit($limit)
->offset($offset)
->get()
->all();
}
public function findById(int $id): ?stdClass
{
return DB::table('users')->where('id', $id)->first();
}
public function insert(array $row): int
{
return (int) DB::table('users')->insertGetId($row);
}
public function updateById(int $id, array $changes): int
{
return DB::table('users')->where('id', $id)->update($changes);
}
public function deleteById(int $id): int
{
return DB::table('users')->where('id', $id)->delete();
}
}
Look at the signatures. The return type is array. The parameters are array $filters and a pair of pagination integers. The method names are findAll, insert, updateById, deleteById — the four CRUD verbs the database already knows. The $filters map keys are column names. If you renamed the status column tomorrow, every caller would break. The breakage would be invisible to the type checker, because the contract is “an array, maybe with these keys.”
Two things tell you this is a DAO:
-
It returns rows, not objects. A
stdClassfromDB::table('users')->first()is a row with acreated_atfield that is a string, anemail_verified_atthat might be null or a Carbon instance depending on which day Laravel won that argument, and no behavior. The caller has to know the column names. The caller now depends on the schema. -
Its method names are storage verbs.
insert,update,deleteare SQL statements. They describe what the database does, not what the business asks for. There is noregister, noverifyEmail, nosuspend, noreactivateAfterPayment. The class has no opinion about the business because it doesn’t speak the business’s language.
This is fine as a layer. DAOs are useful. They are thin, predictable wrappers around SQL, and they have a place in real systems. The problem is the sticker. Once you call this class UserRepository and register it in the container under the same name, the team stops looking for the actual repository. There isn’t one. The use cases reach into this DAO directly, the controllers reach into it through a service that wraps two calls. The test suite mocks it with vendor types and column-name strings, so the tests end up coupled to the schema too.
A useful sanity check: imagine deleting every line of Eloquent, every DB::table call, every Doctrine type from the project. Does the interface you defined still describe something the business cares about? If yes, you have a port. If the file collapses into nonsense because its signatures are full of array, stdClass, and column names, you have a DAO that answered a database question instead of a domain one.
The same class, rewritten as a Repository
Now the version that earns the name. The starting point is a domain object that has nothing to do with persistence.
declare(strict_types=1);
namespace AppDomainUser;
final readonly class UserId
{
public function __construct(public string $value) {}
}
final class User
{
public function __construct(
public readonly UserId $id,
public readonly EmailAddress $email,
private UserStatus $status,
public readonly DateTimeImmutable $registeredAt,
) {}
public function status(): UserStatus
{
return $this->status;
}
public function suspend(SuspensionReason $reason): void
{
if ($this->status === UserStatus::Suspended) {
throw new DomainException('User already suspended');
}
$this->status = UserStatus::Suspended;
}
public function reactivate(): void
{
if ($this->status !== UserStatus::Suspended) {
throw new DomainException('Only suspended users can reactivate');
}
$this->status = UserStatus::Active;
}
}
A User knows how to be suspended and how to be reactivated. It does not know how to be saved. That separation is the whole point.
The port lives next to the entity, in the same namespace.
declare(strict_types=1);
namespace AppDomainUser;
interface UserRepository
{
public function find(UserId $id): ?User;
public function save(User $user): void;
public function ofEmail(EmailAddress $email): ?User;
/** @return list */
public function suspendedSince(DateTimeImmutable $cutoff): array;
}
Four methods. Each one is a sentence a product manager would say out loud. Find this user. Save this user. Get me the user with this email. Give me the users who have been suspended since this date. No SQL hides in the signatures, no pagination knobs that have not yet earned their way in, no filter map standing in for the schema. The return types are User, ?User, list: domain objects, not rows.
find() returns ?User and not a thrown exception. The caller, which is a use case, is in a better position to decide what missing means. It can throw UserNotFound, return a 404 from a controller, or take a different branch. The repository reports; the use case interprets.
The Doctrine adapter is where SQL is finally allowed to exist.
declare(strict_types=1);
namespace AppInfrastructurePersistenceDoctrine;
use AppDomainUserEmailAddress;
use AppDomainUserUser;
use AppDomainUserUserId;
use AppDomainUserUserRepository;
use DoctrineDBALConnection;
final class DoctrineUserRepository implements UserRepository
{
public function __construct(private readonly Connection $db) {}
public function find(UserId $id): ?User
{
$row = $this->db->fetchAssociative(
'SELECT id, email, status, registered_at
FROM users WHERE id = :id',
['id' => $id->value],
);
return $row === false ? null : UserMapper::fromRow($row);
}
public function save(User $user): void
{
$this->db->executeStatement(
'INSERT INTO users (id, email, status, registered_at)
VALUES (:id, :email, :status, :registered_at)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
status = EXCLUDED.status',
UserMapper::toRow($user),
);
}
public function ofEmail(EmailAddress $email): ?User
{
$row = $this->db->fetchAssociative(
'SELECT id, email, status, registered_at
FROM users WHERE email = :email',
['email' => $email->value],
);
return $row === false ? null : UserMapper::fromRow($row);
}
public function suspendedSince(DateTimeImmutable $cutoff): array
{
$rows = $this->db->fetchAllAssociative(
'SELECT id, email, status, registered_at
FROM users
WHERE status = :status AND suspended_at >= :cutoff',
['status' => 'suspended', 'cutoff' => $cutoff->format('c')],
);
return array_map(UserMapper::fromRow(...), $rows);
}
}
The class implements the port. It owns the SQL, owns the mapping from rows to User, and is the only file that imports a Doctrine type. Swap PostgreSQL for DynamoDB and only this file changes. The domain doesn’t move.
UserMapper is the boring half of the contract — it takes a row and returns a User, or takes a User and returns an array shaped like the table. It is the one place in the system that knows column names. Every other consumer of User talks to the domain object.
What changes for the caller
The use case that was previously calling UserRepository::insert(['email' => ..., 'status' => 'active']) is now writing PHP, not SQL.
declare(strict_types=1);
namespace AppApplicationUser;
use AppDomainUserEmailAddress;
use AppDomainUserSuspensionReason;
use AppDomainUserUserNotFound;
use AppDomainUserUserRepository;
final readonly class SuspendUser
{
public function __construct(private UserRepository $users) {}
public function execute(SuspendUserInput $input): SuspendUserOutput
{
$user = $this->users->ofEmail(new EmailAddress($input->email))
?? throw new UserNotFound($input->email);
$user->suspend(new SuspensionReason($input->reason));
$this->users->save($user);
return new SuspendUserOutput($user->id->value, $user->status()->value);
}
}
Notice what is not here. No DB::transaction(function () use (...) { ... }). No $user->update(['status' => 'suspended']). No mention of a column. The use case asks the domain to suspend itself, asks the repository to save the result, and returns a DTO. Next year’s storage migration leaves this file untouched.
The test for this use case is also smaller, because the fake repository is an in-memory array-backed class that implements the port. No mock framework. No expects($this->once())->method('update'). No fixture rows with twelve columns. The fake speaks domain, so the test speaks domain.
final class InMemoryUserRepository implements UserRepository
{
/** @var array */
private array $byId = [];
public function find(UserId $id): ?User
{
return $this->byId[$id->value] ?? null;
}
public function save(User $user): void
{
$this->byId[$user->id->value] = $user;
}
public function ofEmail(EmailAddress $email): ?User
{
foreach ($this->byId as $u) {
if ($u->email->equals($email)) {
return $u;
}
}
return null;
}
public function suspendedSince(DateTimeImmutable $cutoff): array
{
return array_values(array_filter(
$this->byId,
fn (User $u) => $u->status() === UserStatus::Suspended,
));
}
}
Twenty-three lines. No container, no migrations, no RefreshDatabase trait, no SQLite-in-memory. The use case is exercised against a real repository with fake storage, which is the actual point of the abstraction.
The checklist for telling them apart
When you open a class labelled Repository, run through this list. If three or more apply, the file is a DAO and the label is a lie.
- The method signatures contain
array,stdClass, or a vendor type (Builder,EntityManager,Model,Connection,Collection). - The parameters include a
$filtersmap keyed by column names. - The method names are CRUD verbs:
insert,update,delete,findAll,findBy. - Pagination is in the port’s signature (
int $limit, int $offset) rather than in a dedicated read-side projection. - The docblock describes columns rather than business concepts.
- Deleting every Eloquent or Doctrine import from the file leaves it nonsensical.
- The file lives under
AppRepositoriesand the project has zero files underAppDomain.
DAOs earn their keep in admin tooling, batch jobs, internal reports, and ad-hoc queries where the cost of a domain object is genuine overhead. Keep them, name them honestly (UserDao, UserQueries, UserDbGateway), and stop pretending they sit inside the hexagon. A Repository is a port; a DAO is an adapter that exposes itself directly. Putting one where the other belongs is how teams convince themselves they have Hexagonal Architecture when what they have is database/sql with a coat of paint.
Where to draw the line
The shortest version of the rule: a Repository returns and accepts domain objects, lives next to the entity it serves, and has method names a product manager would recognize. Everything else is a DAO, a query object, or a read-side projection. Each has its own name and home.
When you find a class that fails the checklist, you have three reasonable moves. Rename it to UserDao and live with the honesty. Wrap it in a real UserRepository port that returns domain objects, and let the use cases stop importing Eloquent. Or, if the system is small enough that a domain object is overkill, leave the DAO where it is and accept that this slice of the app is CRUD, not architecture. All three are fine. Mislabelling the DAO as a Repository is the one move that costs you something every week and gives back nothing.
If this was useful
The Repository-versus-DAO line is one of the four anti-patterns Decoupled PHP tears down chapter by chapter — alongside service-layer dumping grounds, ActiveRecord-as-domain, and anemic-domains-with-fat-services. The book walks the same shape from a one-port checkout up to a full production application with Doctrine, Symfony Messenger, and Guzzle all kept behind ports they don’t get to define. If you want the storage-strategy side of this argument with less PHP and more decision-making, Database Playbook is the companion.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.


