Choosing DB, QueryBuilder, and Repository#
Introduction#
DBLayer intentionally exposes three layers:
DB: process-level orchestration and infrastructure controls.QueryBuilder: per-query SQL composition.Repository: reusable table-level rules and behavior.
You usually use all three in one application, but for different reasons.
Entry Path (How Most Apps Work)#
Most codebases enter through DB first, then branch:
stay on
DBfor infra concerns (transaction boundaries, retries, connection capabilities, telemetry/profiler/pooling), ormove to
DB::table()for ad-hoc SQL composition, ormove to
DB::repository()for reusable table rules.
This is the normal and intended flow in DBLayer.
Quick Decision Matrix#
You Need |
Use |
Why |
|---|---|---|
Start/commit transactions, manage connections, inspect capabilities |
|
Infrastructure concerns live at the facade layer. |
Compose a one-off complex query (joins, CTEs, custom select/having) |
|
Maximum query-shaping flexibility. |
Reuse tenant/soft-delete/hooks/default-order rules across services |
|
Centralized table policy avoids duplicated filters. |
Raw SQL execution with bindings |
|
Fluent builder is optional; raw SQL stays first-class. |
Long-running table scan with stable pagination |
|
Keyset chunking is safer than offset paging under writes. |
Mental Model#
DBanswers: “How should this run?”QueryBuilderanswers: “What SQL should be emitted?”Repositoryanswers: “What table rules must always apply?”
If the question is about operational behavior (timeouts, retries, transactions,
telemetry), start at DB. If it is about SQL shape, start at builder. If it
is about consistent table policies, start at repository.
Practical Scenarios#
1. Health/Readiness check#
Use the facade:
$ok = DB::ping();
$version = DB::version();
2. API list endpoint with dynamic filters#
Use builder for ad-hoc query shape:
$rows = DB::table('users')
->select('id', 'email', 'name')
->when($onlyActive, fn ($q) => $q->where('active', '=', 1))
->when($role !== null, fn ($q) => $q->where('role', '=', $role))
->orderBy('id', 'desc')
->forPage($page, 20)
->get();
3. Multi-tenant table access used in many services#
Use repository so tenant rules are centralized:
$users = DB::repository('users')->forTenant($tenantId);
$active = $users->get(fn ($q) => $q->where('active', '=', 1));
4. Soft-delete + restore workflow#
Use repository feature toggles:
$users = DB::repository('users')->enableSoftDeletes();
$users->deleteById($id);
$users->restoreById($id);
5. Cross-table reporting query#
Use builder for joins and aggregate shaping:
$rows = DB::table('orders as o')
->join('users as u', 'o.user_id', '=', 'u.id')
->select('u.email')
->selectRaw('count(*) as orders_count')
->groupBy('u.email')
->having('orders_count', '>=', 5)
->get();
6. Transaction boundary with retry attempts#
Use facade transaction orchestration; repository/builder calls can live inside:
DB::transaction(function (): void {
DB::table('accounts')->where('id', '=', 1)->update(['balance' => 900]);
DB::table('accounts')->where('id', '=', 2)->update(['balance' => 1100]);
}, attempts: 3);
7. Capability-aware write path#
Use facade capabilities to branch behavior:
if (DB::supportsReturning()) {
$row = DB::table('users')->insertReturning(['email' => $email]);
} else {
DB::table('users')->insert(['email' => $email]);
}
8. Large table backfill job#
Use keyset streaming from builder or repository:
DB::table('events')
->orderBy('id')
->chunkById(1000, function (array $rows): bool {
// process rows
return true;
}, 'id');
Repository-Style App Service Pattern#
If you want repository-oriented naming in your app, wrap DBLayer repository with composition:
use Infocyph\DBLayer\DB;
use Infocyph\DBLayer\Query\Repository;
final class UserRepository
{
public function __construct(private readonly Repository $repo) {}
public static function make(int $tenantId): self
{
$repo = DB::repository('users')
->forTenant($tenantId)
->enableSoftDeletes()
->setDefaultOrder('id', 'desc');
return new self($repo);
}
public function findActiveByEmail(string $email): ?array
{
return $this->repo->first(
fn ($q) => $q->where('active', '=', 1)->where('email', '=', $email)
);
}
}
This keeps domain naming explicit without turning DBLayer into an ORM system.
Laravel-Like Static Repository Facade (Still Repo Style)#
If you want a class that feels like a static repository API (User::find(),
User::create(), User::query()), you can build one on top of repository.
This is still not ORM behavior; it is repository delegation.
DBLayer includes this base class directly:
Infocyph\DBLayer\Repository\TableRepository
use Infocyph\DBLayer\Repository\TableRepository;
use Infocyph\DBLayer\Query\Repository;
abstract class AppTableRepository extends TableRepository
{
protected static function configureRepository(Repository $repository): Repository
{
return $repository->enableSoftDeletes();
}
}
final class User extends AppTableRepository
{
protected static string $table = 'users';
protected static ?string $connection = 'main';
}
$one = User::find(1);
$recent = User::query()->orderBy('id', 'desc')->limit(20)->get();
$tenantActive = User::forTenant(10)->get(fn ($q) => $q->where('active', '=', 1));
$reportRows = User::query('reporting')->limit(20)->get();
What this gives:
repository-oriented class ergonomics
table and connection mapping in one class
full repository features (tenant scope, soft deletes, optimistic locking, hooks, casts)
What this intentionally does not give:
ORM relations/identity map/dirty tracking/unit-of-work
TableRepository Scenarios#
Read-Repository Connection Split#
final class UserReadRepository extends TableRepository
{
protected static string $table = 'users';
protected static ?string $connection = 'reporting';
}
Policy Method Pattern#
final class User extends TableRepository
{
protected static string $table = 'users';
public static function activeForTenant(int $tenantId)
{
return static::forTenant($tenantId)
->get(fn ($q) => $q->where('active', '=', 1));
}
}
One-Off Query Shape#
$recentEmails = User::query()
->select('email')
->where('created_at', '>=', $since)
->orderBy('id', 'desc')
->limit(100)
->pluck('email');
Common Pitfalls#
Treating
DBandRepositoryas interchangeable abstractions. They have different responsibilities.Repeating tenant/soft-delete filters manually in every builder query instead of centralizing them in a repository.
Using offset paging for long-running jobs where
chunkById()would be safer.Mixing optimistic-locking writes with blind updates on the same rows.