ing, and return results. Crucially, it must remain stateless and free of business logic.
namespace App\DataAccess\Engines;
use App\Models\Invoice;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
class InvoiceQueryEngine
{
public function execute(array $filters): Collection|LengthAwarePaginator
{
$query = Invoice::query();
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (!empty($filters['client_id'])) {
$query->where('client_id', $filters['client_id']);
}
if (isset($filters['min_amount'])) {
$query->where('total_amount', '>=', $filters['min_amount']);
}
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('invoice_number', 'like', "%{$filters['search']}%")
->orWhere('customer_email', 'like', "%{$filters['search']}%");
});
}
if (!empty($filters['relationships'])) {
$query->with($filters['relationships']);
}
if (!empty($filters['sort'])) {
$query->orderBy($filters['sort']['column'], $filters['sort']['direction'] ?? 'desc');
} else {
$query->latest('issued_at');
}
if (!empty($filters['paginate'])) {
return $query->paginate($filters['paginate']);
}
return $query->get();
}
}
Why this structure? Centralizing query logic eliminates duplication. The engine acts as a single source of truth for how invoices are filtered, joined, and ordered. Adding a new condition or changing a default sort order requires modifying one location. The engine remains framework-agnostic in its interface; only the implementation touches Eloquent.
Step 2: Convert Repository Methods to Delegates
Rewrite each repository method to construct a parameter array and pass it to the engine. The method signature, return type, and public contract remain identical. Internal implementation shrinks to three lines: define parameters, invoke engine, return result.
namespace App\DataAccess\Repositories;
use App\DataAccess\Engines\InvoiceQueryEngine;
use App\Models\Invoice;
use Illuminate\Database\Eloquent\Collection;
class InvoiceRepository implements InvoiceRepositoryInterface
{
public function fetchAll(): Collection
{
$engine = new InvoiceQueryEngine();
return $engine->execute([
'relationships' => ['client', 'lineItems'],
]);
}
public function fetchByClient(int $clientId): Collection
{
$engine = new InvoiceQueryEngine();
return $engine->execute([
'client_id' => $clientId,
'relationships' => ['lineItems'],
]);
}
public function fetchPending(): Collection
{
$engine = new InvoiceQueryEngine();
return $engine->execute([
'status' => 'pending',
'min_amount' => 0,
'relationships' => ['client', 'lineItems'],
]);
}
public function fetchById(int $invoiceId): ?Invoice
{
$engine = new InvoiceQueryEngine();
return $engine->execute([
'id' => $invoiceId,
'relationships' => ['client', 'lineItems', 'payments'],
])->first();
}
}
Why this structure? The repository no longer owns query construction. It owns domain intent. fetchPending() declares what it needs (status => 'pending', min_amount => 0), and the engine handles the rest. This separation makes the repository trivially testable and eliminates scattered where clauses. Controllers calling $repository->fetchPending() experience zero behavioral change.
Step 3: Wire and Validate
Inject the engine via constructor dependency or instantiate it directly within methods, depending on your container strategy. Ensure the engine validates incoming parameters to prevent malformed queries. Add query logging or metrics collection at the engine boundary to monitor performance during the transition.
The architecture delivers immediate value: query logic is centralized, repository methods are predictable, and upstream consumers remain unaffected. The interface contract stays stable while internal complexity collapses.
Pitfall Guide
1. Overloading Parameters with Business Logic
Explanation: Developers sometimes embed domain rules (e.g., if ($user->isAdmin()) { $params['status'] = 'all'; }) directly into the parameter array. This couples the query engine to business context.
Fix: Keep parameters strictly structural. Resolve business context in the service or controller layer before constructing the parameter array. The engine should only know how to translate keys into query clauses.
2. Ignoring Type Safety in Configuration Arrays
Explanation: Loose associative arrays allow typos, missing keys, or incorrect types to slip into query construction, causing silent failures or malformed SQL.
Fix: Use PHP 8.1+ readonly classes or DTOs to enforce structure. Example: class QueryConfig { public function __construct(public ?string $status = null, public ?int $clientId = null) {} }. Validate at the engine boundary.
3. Bypassing the Engine for "Simple" Queries
Explanation: Developers occasionally write direct Eloquent calls for one-off queries, breaking the single-entry-point guarantee and reintroducing duplication.
Fix: Enforce a strict policy: all data retrieval for the domain must pass through the engine. If a query is truly unique, add a lightweight parameter flag rather than writing raw builder code.
Explanation: Engines that conditionally apply pagination can return full collections when paginate is omitted, causing memory exhaustion on large datasets.
Fix: Define explicit pagination behavior. Either default to a safe limit (e.g., 50 records) or require pagination parameters for list queries. Document the contract clearly.
5. Testing Only the Repository, Not the Engine
Explanation: Teams mock the engine in repository tests, leaving query construction unverified. Bugs in condition application or relationship loading go undetected.
Fix: Write integration tests for the engine using parameter combinations. Verify that ['status' => 'active', 'relationships' => ['client']] produces the expected SQL and eager loads correctly. Use database transactions or in-memory SQLite for fast feedback.
6. Allowing State Mutation in Query Building
Explanation: Reusing engine instances across requests or storing query state in class properties leads to cross-request pollution and unpredictable results.
Fix: Keep the engine stateless. Instantiate per request or use a factory pattern. Never cache query builder instances between calls.
Explanation: Centralizing queries can mask N+1 issues or missing indexes if developers assume the engine "handles optimization."
Fix: Enable query logging during the transition. Monitor EXPLAIN plans for newly constructed queries. Add explicit indexes for frequently filtered columns (status, client_id, issued_at).
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Legacy codebase with stable interfaces | AQC-Delegated Repository | Zero breaking changes; internal refactoring only | Low (development time) |
| New microservice or greenfield project | Direct AQC / Query Builder | Skip repository abstraction; reduce indirection | Medium (architectural decision) |
| High-traffic read-heavy domain | AQC with Caching Layer | Engine centralizes queries; cache keys map to parameter hashes | High (infrastructure) |
| Complex multi-tenant filtering | AQC with Tenant Middleware | Engine applies tenant scope automatically; repository stays clean | Low-Medium |
Configuration Template
namespace App\DataAccess\Engines;
use App\Models\Invoice;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
readonly class InvoiceQueryEngine
{
public function run(array $config): Collection|LengthAwarePaginator
{
$query = $this->buildBaseQuery();
$query = $this->applyFilters($query, $config);
$query = $this->applyRelationships($query, $config);
$query = $this->applySorting($query, $config);
return $this->resolveResult($query, $config);
}
private function buildBaseQuery(): Builder
{
return Invoice::query();
}
private function applyFilters(Builder $query, array $config): Builder
{
if (!empty($config['status'])) {
$query->where('status', $config['status']);
}
if (!empty($config['client_id'])) {
$query->where('client_id', $config['client_id']);
}
if (isset($config['min_amount'])) {
$query->where('total_amount', '>=', $config['min_amount']);
}
return $query;
}
private function applyRelationships(Builder $query, array $config): Builder
{
if (!empty($config['relationships']) && is_array($config['relationships'])) {
$query->with($config['relationships']);
}
return $query;
}
private function applySorting(Builder $query, array $config): Builder
{
if (!empty($config['sort']) && is_array($config['sort'])) {
$column = $config['sort']['column'] ?? 'issued_at';
$direction = in_array($config['sort']['direction'], ['asc', 'desc'])
? $config['sort']['direction']
: 'desc';
$query->orderBy($column, $direction);
} else {
$query->latest('issued_at');
}
return $query;
}
private function resolveResult(Builder $query, array $config): Collection|LengthAwarePaginator
{
if (!empty($config['paginate']) && is_int($config['paginate']) && $config['paginate'] > 0) {
return $query->paginate($config['paginate']);
}
return $query->get();
}
}
Quick Start Guide
- Extract Query Logic: Identify the most duplicated query pattern in your repository. Copy its
where, with, and orderBy calls into a new engine class.
- Define Parameter Keys: Map each condition to a clear key (e.g.,
status, client_id, relationships). Document expected types and defaults.
- Refactor One Method: Replace a single repository method with a parameter array and engine call. Verify behavior matches the original.
- Add Tests: Write a test that passes different parameter combinations to the engine and asserts the generated SQL or returned dataset.
- Roll Out Incrementally: Convert remaining methods one by one. Monitor query logs for performance regressions. Deploy when all methods delegate successfully.