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

happyr/doctrine-specification

Happyr Doctrine Specification brings the Specification pattern to Doctrine: compose small, reusable, testable specification classes into query expressions instead of bloated repositories with long QueryBuilder calls and duplicated logic.

View on GitHub
Deep Wiki
Context7

Getting Started

This package is Doctrine ORM only — it does nothing for Eloquent. If your Laravel app is a standard Eloquent app, stop here: the pattern still applies, but you'd reimplement it with query objects/scopes. Everything below assumes you already have Doctrine wired in (e.g. laravel-doctrine/orm with a ManagerRegistry/EntityManager) or are building a DDD-style Laravel service where Doctrine is the persistence layer.

  1. Install (pin to the Doctrine ORM 3-compatible line):
composer require happyr/doctrine-specification:^2.2

v2.2.0 supports both Doctrine ORM 2 and 3; anything <2.0 predates the BaseSpecification/context fixes and will bite you. PHP 7.2+ is required, so any current Laravel version is fine.

  1. Make your repository extend the library's repository so match() exists:
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;

#[ORM\Entity(repositoryClass: AdvertRepository::class)]
class Advert { /* ... */ }

class AdvertRepository extends EntitySpecificationRepository {}

If you forget repositoryClass in the mapping (attribute, XML or YAML), $repo->match() simply won't exist — this is the #1 first-run failure.

  1. Replace one messy repository method with a spec:
use Happyr\DoctrineSpecification\Spec;

$spec = Spec::andX(
    Spec::eq('ended', 0),
    Spec::orX(
        Spec::lt('endDate', new \DateTimeImmutable()),
        Spec::andX(Spec::isNull('endDate'), Spec::lt('startDate', new \DateTimeImmutable('-4 weeks')))
    )
);

return $this->em->getRepository(Advert::class)->match($spec);

Where to look first:

  • docs/0-usage.md and docs/1-creatingSpecs.md in the repo — short but the only narrative docs.
  • src/Spec.php — the whole static surface (eq, lt, in, isNull, isEmpty, join, orderBy, limit, count, selectNew, andX, orX, not…) in one file. This is the real reference.
  • The CHANGELOG/release notes — the 2.0 upgrade notes explain join ordering, context and repository renames.

First use case: pick one bloated repository method with 3+ optional arguments, extract it into a BaseSpecification subclass, and call match(). That's the smallest change that proves whether the pattern earns its keep in your codebase.

Implementation Patterns

Static composition for one-off queries. Spec::andX(Spec::eq(...), Spec::orX(...)) is a value object graph — assign it to a local, return it from a private method, log it, whatever. Keep it inside the repository or a thin query service, not in a controller.

Named specification classes for reusable rules. The core win over QueryBuilder is composability:

class AdvertsWeShouldClose extends BaseSpecification
{
    public function getSpec(): Specification
    {
        return Spec::andX(
            Spec::eq('ended', 0),
            Spec::orX(
                Spec::lt('endDate', new \DateTimeImmutable()),
                Spec::andX(Spec::isNull('endDate'), Spec::lt('startDate', new \DateTimeImmutable('-4 weeks')))
            )
        );
    }
}

class OwnedByCompany extends BaseSpecification
{
    public function __construct(private int $companyId, ?string $context = null)
    {
        parent::__construct($context);
    }

    public function getSpec(): Specification
    {
        return Spec::andX(
            Spec::join('company', 'c'),          // join FIRST
            Spec::eq('id', $this->companyId, 'c')
        );
    }
}

Then compose freely, including with Spec::not($spec) — something the old filterX(QueryBuilder $qb) style can't do without duplication.

Two composition layers. Keep the repository dumb (match() / iterate() passthrough) and let application services compose specs:

public function closableForCompany(Company $company): array
{
    return $this->adverts->match(
        Spec::andX(new AdvertsWeShouldClose(), new OwnedByCompany($company->id), Spec::orderBy('endDate', 'ASC'))
    );
}

This avoids growing a 30-method repository — the exact problem the README opens with.

Same rule, two execution modes. Because specs also implement isSatisfiedBy() / filterCollection(), one rule can drive both the SQL query and an in-memory check (dry-run previews, CSV imports, feature-flag previews of what a rule would select):

$spec = Spec::andX(Spec::eq('gender', 'F'), Spec::gt('points', 9000));

$matching   = $this->em->getRepository(Player::class)->match($spec);   // DB
$preview    = $spec->filterCollection($importedRows);                  // arrays or objects
$isEligible = $spec->isSatisfiedBy($player);                           // single entity/array

Apply to a builder you already have. When you need custom hydration, a Paginator, a second query, or eager-loading tweaks, drop one level down:

$qb = $this->em->createQueryBuilder()->select('a')->from(Advert::class, 'a');
$spec->modify($qb, 'a');
$query = $qb->getQuery();

This is also the escape hatch for ->getQuery()->getResult(AbstractQuery::HYDRATE_ARRAY) and for iterate()-style streaming of large result sets.

Query-shaping specs as first-class citizens. Spec::orderBy, Spec::limit, Spec::offset, Spec::having, Spec::select, Spec::count()/Spec::countDistinct(), Spec::selectNew(...) (DTO projection) all compose with filters in the same andX. That means pagination/sorting can be a spec produced by a request DTO:

Spec::andX($filters, Spec::orderBy($sortField, $sortDir), Spec::limit($perPage), Spec::offset($page * $perPage))

Testing. Specs are cheap to unit-test without a DB via isSatisfiedBy()/filterCollection() on fixtures; add one SQLite-backed integration test per spec that runs match() and asserts ids. Keep a shared SpecTestCase with an in-memory EntityManager — specs are pure enough that the unit tests catch most composition mistakes.

Organise by domain. Put specs next to the thing they describe (app/Domain/Advert/Specification/), and wrap the library's static factory in your own AppSpec extends Spec (or a facade) so the vendor namespace doesn't spread across the codebase — you're already coupling to a 449-star package with 0 listed dependents.

Gotchas and Tips

  • Alias/context double-qualification. Specs take unqualified field names and prepend the DQL alias from the context. Spec::eq('c.id', $id, 'c') can produce c.c.id. Pass plain 'id' with 'c' as the third argument (or parent::__construct('c')).
  • null is not IS NULL. Spec::eq('endDate', null) won't do what you expect — use Spec::isNull() / Spec::isNotNull() / Spec::isNotEmpty(). Same for isMemberOf vs in.
  • Join ordering matters. v2.0 fixed "define JOIN before using the new alias from it": within an andX, put Spec::join(...) first and use it in later children. Don't rely on <2.0 behaviour.
  • DateTime captured at construction time. Because specs are constructed before execution, new \DateTime() in a spec is frozen at build time. Prefer \DateTimeImmutable and compute "now"/relative windows inside getSpec() so long-lived spec instances (e.g. in a container or cached config) don't go stale.
  • Don't reuse the same spec instance twice in one query. Spec::andX($spec, $spec) (or the same spec used in two branches) can collide on generated parameter names and silently bind the wrong value. Construct fresh instances per query — they're cheap value objects.
  • Empty composites and empty IN. Spec::in('id', []) yields invalid SQL; Spec::andX() with no children shouldn't reach the query builder. Guard at the spec boundary (return an always-true/always-false spec instead of an empty one).
  • LIMIT + to-many joins truncates rows. Doctrine applies the limit at the SQL level. Use Paginator for paginated fetch-joins, or iterate()-style streaming for large exports, and test with a collection-backed association.
  • match() returns an array, not a query. For a single result use Spec::limit(1) + reset/[0], or build the QueryBuilder yourself. Don't chain Eloquent-ish methods onto it.
  • match is a PHP 8 keyword. It's legal as a method name, but string callables ('repo, method'-style, some proxy/higher-order usage) get awkward. Direct $repo->match($spec) is fine.
  • Metadata cache. After changing repositoryClass in attributes/mapping, Doctrine's metadata cache will still hand you an EntityRepository. Clear it (php artisan cache:clear + your doctrine metadata cache, or disable it in local env) before debugging "method match() does not exist".
  • Version pinning. happyr/doctrine-specification floats against Doctrine/DBAL majors that break in different ways. Lock ^2.2 and treat ORM 3 upgrade as a deliberate change; also make sure doctrine/orm is the same constraint in every dependency (Composer will tell you, but the error is cryptic).
  • In-memory semantics are simplified. isSatisfiedBy()/filterCollection() support a subset of operators and use loose value comparison. Numeric strings from JSON imports, DateTime instances vs date strings, and nulls will not always match what SQL does. Never assert "the spec says yes" from in-memory evaluation in a critical write path — query the DB for the authoritative answer.
  • Debugging is harder. The generated DQL is verbose and parameter-heavy. In local env log $qb->getQuery()->getDQL() (or use the Laravel debugbar / Doctrine SQL logger) whenever a spec misbehaves; comparing the DQL of the old hand-written method against the spec output is the fastest sanity check.
  • Extension points are first-class, use them. Implement Filter::getFilter(QueryBuilder, string $dqlAlias): string for a custom predicate, QueryModifier::modify(...) for query-level changes, or an Operand for custom values, then expose it from your own AppSpec:: facade. This is how you add domain-specific predicates (PostGIS distance, JSON extraction) without forking.
  • Don't let the vendor leak. With ~449 stars and no listed dependents, assume you may replace it. Your specs should extend your BaseSpecification wrapper (delegating to the vendor's) and your services should depend on your facade, so a future swap to hand-rolled query objects is a one-file change.
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.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc