One Specification, Two Targets
Mocking a repository in tests does not verify your query; it only verifies that the method was called. Run a real database instead and the test suite slows to a crawl, so you run it less often. Either way, your filter logic ends up duplicated: once in the production code that hits the database, once in the mocks that bypass it. The two drift apart silently until a bug ships to production.
What Mocks Actually Verify
When you mock userRepository.findByEmail($email), the test asserts that findByEmail was called with the right argument. It does not assert that the underlying SQL fragment matches the right rows. If you rename a column, change a join condition, or add a WHERE clause, the mocked test still passes. The bug appears in integration, or worse, in production.
The alternative is to spin up a real database for every test. That works, but it costs seconds per test instead of milliseconds. Developers respond predictably: they run the slow suite less often, and bugs slip through.
The Specification Pattern, Revisited
The Specification Pattern, in its canonical form (Eric Evans, Domain-Driven Design, 2003; Evans and Fowler, Specifications, 2002), is an object that encapsulates a business rule as a predicate. The original interface has one method:
You compose specs with and(), or(), not(), then pass them to a collection or repository which evaluates each candidate. That works for in-memory predicates but it does not scale: if you want to filter a million rows that live in a database, you do not want to load them all into PHP first just to discard most of them.
Fowler discusses this exact case in the original paper under the name Query Specification: a spec that translates itself into a database query. Phexium implements this variant directly. The interface has two methods instead of one:
namespace Phexium\Domain\Specification;
interface SpecificationInterface
{
public function toSql(): array;
public function toInMemoryFilter(): callable;
}
toSql() returns a SQL fragment with named parameters, ready to be bound by PDO. toInMemoryFilter() returns a closure that filters PHP arrays. The same business rule has two compilation targets, in a single class.
A concrete spec from the demo app:
namespace AppDemo\User\Domain\Specification;
use AppDemo\User\Domain\Email;
use Phexium\Domain\Specification\SpecificationInterface;
final readonly class EmailSpecification implements SpecificationInterface
{
public function __construct(
private Email $email
) {}
public function toSql(): array
{
return [
'sql' => 'email = :email',
'params' => ['email' => $this->email->getValue()],
];
}
public function toInMemoryFilter(): callable
{
return fn (array $row): bool => $row['email'] === $this->email->getValue();
}
}
Twenty lines. The two representations sit next to each other, so a change to one forces you to update the other.
Composition with Boolean Algebra
A flat list of predicates is not enough for real queries. You combine them: "active AND email matches", "draft OR archived", "not deleted". Phexium ships the full boolean algebra: BinaryAndSpecification, BinaryOrSpecification, UnaryNotSpecification, plus Xor, Nand, Nor, Xnor, Implies, AndNot, and the constants AlwaysTrue and AlwaysFalse.
Each composite implements both compilation targets recursively. The AND case is the simplest:
final readonly class BinaryAndSpecification extends BinaryAbstract
{
protected function getSqlTemplate(): string
{
return '(LEFT) AND (RIGHT)';
}
protected function evaluateBinary(callable $left, callable $right, array $row): bool
{
return $left($row) && $right($row);
}
}
The base class BinaryAbstract handles parameter suffixing so two specs that bind :email do not collide in the final SQL output. You compose specs at the call site:
$spec = new BinaryAndSpecification(
new EmailSpecification($email),
new UnaryNotSpecification(new IsArchivedSpecification())
);
$users = $userRepository->findBy($spec);
The repository code does not change based on the storage backend. The injected driver executes the resulting query against Postgres, SQLite, MySQL, or an in-memory array of rows.
Trade-offs
Each spec must implement both compilation targets. There is no free ride: if you reach for a SQL function the in-memory filter does not support, you have to write the PHP equivalent yourself.
Specs are not full SQL. Joins, window functions, and aggregates do not fit this abstraction. They live in dedicated query handlers that go through the SqlDriver directly, bypassing specifications altogether.
Parameter suffixing adds a layer of indirection in the generated SQL. When you read query logs you see :email_0_1 instead of :email, which is worth knowing when you debug.
The pattern covers the common case: filtering rows by predicate. The rest still goes through the driver, just not via specifications.
What This Enables
Acceptance tests run against the InMemory adapter in milliseconds. The same specs run against Postgres in production. There is no second query path to test, no mock to keep in sync when a filter evolves, no drift between what the test thinks the query does and what the database actually does.
Adding a new business filter means writing one class with two methods. Adding a new operator means writing one class with two methods. The combinatorics stay flat, and the test suite stays fast.