Skip to content

Recipe Repository Pattern

Muhammet Şafak edited this page May 24, 2026 · 1 revision

Recipe — Repository Pattern

The DB:: facade is convenient but it ties every consumer to a process-global. For testable, dependency-injected code, push the database into your repository class via the constructor.

Problem

A UserService needs to read users. With the facade, the service is hard to test in isolation:

finalclass UserService
{
publicfunctionfindByEmail(string$email): ?array
{
returnDB::select('*')
->from('users')
->where('email', $email)
->read()
->asAssoc()
->row();
}
}

The test has to bootstrap DB::createImmutable() against a real database — heavy, slow, and globally stateful.

Code

Move the database to a constructor argument and the consumer can be unit-tested against any DatabaseInterface:

namespaceApp\Repository;
useInitORM\Database\Interfaces\DatabaseInterface;
finalclass UsersRepository
{
publicfunction__construct(
privatereadonlyDatabaseInterface$db,
) {
}
publicfunctionfindByEmail(string$email): ?array
{
return$this->db
->select('*')
->from('users')
->where('email', $email)
->read()
->asAssoc()
->row();
}
publicfunctionfindById(int$id): ?array
{
return$this->db
->select('*')
->from('users')
->where('id', $id)
->read()
->asAssoc()
->row();
}
publicfunctioncreate(array$row): int
{
$this->db->create('users', $row);
return (int) $this->db->insertId();
}
publicfunctionupdate(int$id, array$set): void
{
$this->db->where('id', $id)->update('users', $set);
}
publicfunctiondelete(int$id): void
{
$this->db->where('id', $id)->delete('users');
}
}

Wire it up

In a DI container

$container->set(DatabaseInterface::class, function () {
returnnewInitPHP\Database\Database(require__DIR__ . '/config/database.php');
});
$container->set(UsersRepository::class, function ($c) {
returnnewUsersRepository($c->get(DatabaseInterface::class));
});

Without a container (manual wiring)

$db = newInitPHP\Database\Database(require__DIR__ . '/config/database.php');
$users = newUsersRepository($db);
$ada = $users->findByEmail('ada@example.com');

With the facade (the bridge pattern)

If your app uses the facade everywhere but you want one repository to be DI-friendly:

DB::createImmutable($config); // legacy bootstrap$users = newUsersRepository(DB::getDatabase());

The repository still takes a DatabaseInterface; the rest of the app keeps using DB::.

Tests

In an isolated unit test:

finalclass UsersRepositoryTest extends TestCase
{
privateDatabaseInterface$db;
privateUsersRepository$repo;
protectedfunctionsetUp(): void
{
$connection = SqliteHelper::makeConnection();
$this->db = newInitPHP\Database\Database($connection);
$connection->getPDO()->exec(
'CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT )'
);
$this->repo = newUsersRepository($this->db);
}
publicfunctiontestFindByEmailReturnsTheStoredRow(): void
{
$id = $this->repo->create(['name' => 'Ada', 'email' => 'ada@example.com']);
$found = $this->repo->findByEmail('ada@example.com');
self::assertNotNull($found);
self::assertSame($id, (int) $found['id']);
}
publicfunctiontestFindByEmailReturnsNullForMissingRows(): void
{
self::assertNull($this->repo->findByEmail('nope@example.com'));
}
}

No DB::createImmutable(), no process-global state, no static singletons. Each test gets its own in-memory SQLite database.

Repository vs Active-Record Model

Both are valid; the choice depends on how big the data access surface is.

Active-Record ModelRepository
One class per table, CRUD lives on the class.Each repository may span multiple tables (joins, aggregates).
Reaches for the facade by default; opt-in $credentials for secondary DBs.Takes the database via the constructor — explicit.
Soft deletes, timestamps, gates baked in.You implement those yourself if you need them.
Best for "row in, row out" CRUD.Best for domain operations that span tables.

A common shape: most tables get a Model; one or two heavy domain operations (the order-checkout pipeline, the report generator) get a dedicated repository that composes multiple Models or talks directly to DatabaseInterface.

Variations

Inject an interface, not the class

namespaceApp\Repository;
interface UsersReadRepository
{
publicfunctionfindByEmail(string$email): ?array;
publicfunctionfindById(int$id): ?array;
}
finalclass UsersRepository implements UsersReadRepository
{
// implementation as above
}

Now UserService depends on UsersReadRepository — a one-class read-only narrowing. Mocking it in a test is trivial; substituting it for an HTTP-backed read model later is also trivial.

Add read-through caching

finalclass CachedUsersRepository implements UsersReadRepository
{
publicfunction__construct(
privatereadonlyUsersReadRepository$inner,
privatereadonlyCacheInterface$cache,
) {
}
publicfunctionfindById(int$id): ?array
{
return$this->cache->get('user:' . $id, fn () => $this->inner->findById($id));
}
}

The decorator pattern is impossible with the active-record style; trivial with a repository interface.

When NOT to use this pattern

For a 50-line script that does one INSERT, the facade is fine. The repository pattern earns its keep when you have a non-trivial number of consumers, multiple tests, or a need to swap implementations (read replica, in-memory test double, HTTP-backed read model).

Next

Clone this wiki locally