Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Doctrine Specification Laravel Package

rikbruil/doctrine-specification

A Doctrine specification-pattern package for composing reusable, testable query criteria. Combine specifications with and/or/not, apply them to repositories, and keep complex filtering logic out of entities and controllers. Supports modular filters and cleaner, more maintainable data access.

Deep Wiki
Context7

Getting Started

rikbruil/doctrine-specification adds the Specification pattern on top of Doctrine ORM. You compose small, reusable, testable objects into one query instead of fattening repositories with if ($request->has('status')) { $qb->andWhere(...) } chains.

Install and requirements

composer require rikbruil/doctrine-specification

It targets PHP 7.4+/8.x and Doctrine ORM 2.x. In a Laravel app you'll almost always pair this with laravel-doctrine/orm (so you have an EntityManagerInterface bound in the container) — plain Eloquent models are not in scope.

Verify the public surface first. Open the two directories that matter:

  • src/Filter/ — leaf predicates (Equals, In, Like, GreaterThan, IsNull, Between, …)
  • src/Query/ — query modifiers (Select, Where, OrderBy, GroupBy, Having, Join, SetMaxResults, …)

The abstract Specification class is what you extend; its static combinators (and, or, not) and its getQuery() / match() entry points are what you'll call day to day. Confirm exact namespaces in the README before writing production code — the package shares an API shape with hmlb/doctrine-specification, so snippets found online may reference the wrong namespace.

First use case — a "list published posts" endpoint:

use Rikbruil\DoctrineSpecification\Filter\Equals;
use Rikbruil\DoctrineSpecification\Filter\GreaterThanOrEqual;
use Rikbruil\DoctrineSpecification\Query\OrderBy;
use Rikbruil\DoctrineSpecification\Specification;

$spec = Specification::and(
    new Equals('status', 'published'),
    new GreaterThanOrEqual('publishedAt', new \DateTimeImmutable('-30 days')),
    new OrderBy('publishedAt', 'DESC')
);

$posts = $spec->getQuery($em)->getResult();

getQuery() builds a fresh QueryBuilder from the entity manager, applies every node, and returns a Doctrine Query. There is no entity class argument baked in — the root entity is defined by the specification's Select/From behaviour (in practice you set the alias in your spec or use the Select query node). Verify this with a one-page spike before designing a repository API around it.

The payoff lands immediately: the same $spec object can drive ->getResult(), ->getOneOrNullResult(), or ->getArrayResult(), and it can be unit tested without touching a database by asserting on $qb->getDQL().

Implementation Patterns

1. Reusable, named specifications as first-class classes. This is the whole point — a spec should read like a domain rule, not like SQL.

namespace App\Doctrine\Specification;

use Rikbruil\DoctrineSpecification\Filter\Equals;
use Rikbruil\DoctrineSpecification\Filter\In;
use Rikbruil\DoctrineSpecification\Specification;

final class PublishedPostSpecification
{
    public static function forStatus(string ...$statuses): Specification
    {
        return new In('status', $statuses === ['published'] ? 'published' : $statuses);
    }
}

Prefer final + protected constructor (or a static factory) so these aren't subclassed into a hierarchy nobody asked for. Composition is the extension mechanism, not inheritance.

2. Compose in the repository, not in the controller. Keep EntityManagerInterface out of controllers and keep HTTP concerns (status=published&sort=-date) in a form request or a small request-to-specification mapper.

final class PostRepository
{
    public function __construct(private EntityManagerInterface $em) {}

    public function search(Specification $spec): array
    {
        return $spec->getQuery($this->em)->getResult();
    }
}

3. Use match() to attach a spec to an existing QueryBuilder. When a query needs a base layer (a join, a select-projection, a tenant filter) plus a conditional spec, match() mutates your builder in place:

$qb = $em->createQueryBuilder()->select('p')->from(Post::class, 'p');
$qb->innerJoin('p.author', 'a')->andWhere('a.tenant = :tenant')->setParameter('tenant', $tenantId);

$searchSpec->match($qb);           // appends status/date/order constraints

$results = $qb->getQuery()->getResult();

This is the workhorse for Laravel: build the base builder once in a repository, hand it through spec-matching for filters/sorting, then page it.

4. Build a "filter bag" from HTTP input. A common pattern is a spec factory that turns validated request data into Specification::and(...) nodes and silently drops empty values:

$nodes = [];
if ($status = $request->validated('status')) {
    $nodes[] = new Equals('status', $status);
}
if ($q = $request->validated('q')) {
    $nodes[] = new Like('title', "%{$q}%");
}
$nodes[] = new OrderBy($request->validated('sort') ?? 'createdAt', 'DESC');
$nodes[] = new SetMaxResults(25);
$nodes[] = new SetFirstResult(($page - 1) * 25);

$spec = Specification::and(...$nodes);

Note Specification::and with a single node should just return that node; with zero nodes it should be a no-op — confirm the zero-arg behaviour before relying on it.

5. Composition for optional logic.

$spec = Specification::and(
    new Equals('status', 'active'),
    Specification::not(new IsNull('emailVerifiedAt')),
    Specification::or(
        new Equals('role', 'admin'),
        new GreaterThanOrEqual('reputation', 100)
    )
);

Because each node is a real object, you can snapshot it in a test and assert its DQL — no database, no fixtures.

6. In-memory predicates. If a spec needs to be evaluated against an already-hydrated entity (e.g. a policy check reusing the same rule), look for the isSatisfiedBy()-style method on the spec. It's only as correct as you implement it, so keep it in sync with the match() logic or delegate both to the same predicate callback.

7. Pagination + counting. Don't reuse the same $spec object for the count query if it contains OrderBy/SetMaxResults — build a count spec from the same filter nodes but without the ordering and window constraints. Specs are immutable in spirit, so extract the shared filter spec and compose it twice.

Gotchas and Tips

match() mutates; getQuery() doesn't. Calling $spec->match($qb) twice on the same builder will append duplicate WHERE clauses and duplicate parameter names. Specs themselves are stateless and safe to share, but QueryBuilders are not.

Parameter-name collisions are the #1 runtime bug. When you nest the same filter twice (Equals('status', 'a') and OrX with Equals('status', 'b')), the generated :param name may clash and silently bind one value. If you hit "Parameter X not defined" or wrong results, inspect $qb->getParameters() after matching. Fix by using distinct fields/aliases or by wrapping the spec with your own parameter-naming layer.

Aliases are the second most common bug. Filter nodes typically need to know which alias the field belongs to. If your base query uses from(Post::class, 'p') but your spec emits status without an alias, you'll get SQL errors. Either standardise on a root alias (p everywhere) or make your spec classes accept an alias in the constructor.

Debug with DQL, not the result set. $qb->getDQL() is your test oracle and your debugger. $query->getSQL() plus Doctrine's SQL logger (in laravel-doctrine this is the doctrine.sql_logger channel or the debug stack) tells you what actually shipped. Assert DQL fragments in unit tests — it's fast, deterministic, and catches the mutation bugs above.

Ordering and SetMaxResults are query-level, not filter-level. Keep them in separate specs and apply them last. A spec used for a count query must not carry them.

Joins/eager loading are weak spots. There's no "fetch join for hydration" magic here. If an endpoint N+1s, add a custom Query\Join-style spec or a dedicated hydration strategy — don't try to solve fetch mode inside a Filter node.

Custom specs: implement one method. Extend Specification and constrain the QueryBuilder. Keep constructors pure (store field + value only); all builder interaction happens at match time. That keeps the spec serialisable and unit-testable.

Doctrine 3 compatibility is not guaranteed. Pin doctrine/orm in composer.json and let the constraint fail loudly at install time rather than at runtime after an upgrade.

Laravel container quirks. If you're on laravel-doctrine/orm, inject Doctrine\ORM\EntityManagerInterface and let the package's service provider alias it. Don't new EntityManager(...) in a spec factory, and don't resolve the EM inside a static spec helper — pass it into getQuery()/match() from the caller. This also keeps specs trivially testable with a mocked EntityManagerInterface.

Don't let specifications become a second ORM. The temptation is to encode every query in spec classes. Use them where composition and reuse earn their keep — list endpoints, policy-shaped filters, shared tenant scoping. A one-off SELECT in a report service is clearer as plain DQL.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk