Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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');
}
}$container->set(DatabaseInterface::class, function () {
returnnewInitPHP\Database\Database(require__DIR__ . '/config/database.php');
});
$container->set(UsersRepository::class, function ($c) {
returnnewUsersRepository($c->get(DatabaseInterface::class));
});$db = newInitPHP\Database\Database(require__DIR__ . '/config/database.php');
$users = newUsersRepository($db);
$ada = $users->findByEmail('ada@example.com');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::.
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.
Both are valid; the choice depends on how big the data access surface is.
Active-Record Model | Repository |
|---|---|
| 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.
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.
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.
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).
- Models — the alternative active-record shape.
- Multiple Connections — when the repository needs a non-shared
DatabaseInterface. - Recipe — Audit Log — repository-shaped code in production use.
initphp/database · MIT License · part of the InitPHP family
Source · Issues · Discussions · Packagist · Contributing · Security Policy
Getting Started
Core API
ORM
Advanced
DataTables Helper
Recipes
- Index
- — Pagination
- — Search & Filters
- — Upsert / REPLACE INTO
- — Audit Log
- — DataTables Bootstrap
- — Repository Pattern
Reference
Migration & Help