useless-soft/queries-doctrine
Doctrine ORM adapter for useless-soft/queries, applying reusable query filters and criteria to Doctrine query builders. Adds Doctrine support to a composable query-object abstraction. Small, low-adoption package (2 stars); review maintenance status and docs before use.
This is the Doctrine bridge for the useless-soft/queries query-object/bus library: it lets you express reads as small Query DTOs handled by services that talk to Doctrine, instead of scattering EntityManager calls through controllers. The repo is archived, has effectively no users, and carries an unrecognised license — so treat it as vendored source you own, not a dependency you track.
1. Audit before installing. Open composer.json and src/. Expect PHP 7.x-era code and doctrine/orm ^2.x constraints; there is almost certainly no ORM 3 support and no PHP 8.3+ CI. Read LICENSE (GitHub reports NOASSERTION, which usually means a non-standard or missing license file — if it's missing, you have no grant to redistribute).
2. Install from VCS, pinned to a commit. It may not have a usable Packagist release:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/UselessSoft/queries-doctrine" }
],
"require": {
"useless-soft/queries": "^1.0",
"useless-soft/queries-doctrine": "dev-master#<commit-sha>"
}
}
Fork it into your own org first if you plan to patch it — that's the realistic path for an archived repo.
3. Find the two things the package actually gives you. Read src/ and confirm the exact FQCNs; typically this is (a) an abstract Doctrine-backed handler that injects EntityManagerInterface, and (b) a translator/mapper from the library's criteria objects to Doctrine's Criteria for Repository::matching(). It is not a Symfony bundle with autoconfiguration — you register handlers as services yourself.
4. First use case: move one list endpoint behind a query. Pick a controller action that builds a QueryBuilder inline. Extract it:
final class FindActiveUsers implements Query
{
public function __construct(
public readonly int $page = 1,
public readonly int $perPage = 20,
) {}
}
final class FindActiveUsersHandler
{
public function __construct(private EntityManagerInterface $em) {}
public function __invoke(FindActiveUsers $query): Pagerfanta
{
$qb = $this->em->createQueryBuilder()
->select('u')->from(User::class, 'u')
->where('u.active = :active')->setParameter('active', true)
->orderBy('u.createdAt', 'DESC');
return (new Pagerfanta(new DoctrineORMAdapter($qb, fetchJoinCollection: false)))
->setCurrentPage($query->page)
->setMaxPerPage($query->perPage);
}
}
Wire it into the query bus the same way the base useless-soft/queries docs wire any other handler. Once one endpoint works, the pattern generalises mechanically.
One handler per query, no shared base logic. The bridge's abstract handler only saves you an EntityManagerInterface constructor; don't build a hierarchy on top of it. Handlers should be final, readonly-ish, and free of state.
Let the query own the criteria. Where the bridge's criteria translation applies, expose it from the query object so handlers stay thin and the criteria is unit-testable without a database:
final class FindUsersByRole implements Query
{
public function __construct(private string $role) {}
public function getCriteria(): Criteria
{
return Criteria::create()
->where(Criteria::expr()->eq('role', $this->role))
->orderBy(['createdAt' => 'DESC'])
->setMaxResults(50);
}
}
// handler
return $this->em->getRepository(User::class)->matching($query->getCriteria());
This is the sweet spot for the package: simple equality/IN/ORDER BY/limit filters become declarative and reusable across repositories.
Split read models from entity reads. Use matching() (and the criteria translator) when you need managed entities you'll mutate or pass to domain code. Use raw DBAL or a select new DTO(...) DQL query for list screens, exports, and dashboards — Doctrine's criteria layer cannot express joins or partial selects, and array hydration is dramatically cheaper for read-only views.
Pagination belongs in the handler, not the query. Return a Pagerfanta (or a small Page<T> value object) from the handler so controllers never see setFirstResult/setMaxResults. Keep page/perPage on the query so they're validated at construction.
Don't flush inside read handlers. Treat handlers as reads unless explicitly named otherwise. For the write side, either follow the parent library's command/handler split, or wrap in $em->transactional(fn () => ...) at the service boundary. Mixed read/write handlers are where this pattern rots.
Compose with bus middleware. The parent library's bus is the extension point: add caching (keyed off the query DTO, which serialises beautifully), timing/logging, and a tenant-scoping middleware that injects a tenantId filter — far cleaner than repeating ->andWhere('t.id = :tenant') in every handler.
Testing. Boot a EntityManager against SQLite :memory:, run SchemaTool::createSchema(), load fixtures, and assert on handler output. Because queries are plain objects, you can also unit-test criteria translation with zero database:
$criteria = (new FindUsersByRole('admin'))->getCriteria();
self::assertSame('admin', $criteria->getWhereExpression()->getComparison()->getValue());
Integration tip: if you're on Symfony, tag handlers with messenger.message_handler-style tags or your bus's tag, and register the bridge's translator as a service; there's no recipe for this package, so a 20-line services.yaml block is expected.
Archived means no fixes — budget for ownership. With 0 dependents and 2 stars, you are the only maintainer. Fork on day one, add CI (PHP matrix + doctrine/orm 2.x/3.x to see what breaks), and expect to patch Doctrine deprecations yourself. Do not put this on a long-lived product without the fork.
License is a real blocker, not a formality. NOASSERTION on GitHub means the detector found no recognised license. Check the actual LICENSE/COPYING file and the license key in composer.json. If neither exists, default copyright applies and you have no right to use it in a distributed product — get a written grant or vendor a clean-room equivalent (the pattern is simple enough to reimplement in an afternoon).
Doctrine Criteria is much weaker than it looks. Repository::matching() does not support joins, and its expression support is limited — no orX at the top level in practice, no nested and/or beyond what the persister flattens, no fetch-join, no partial selects. The moment a screen needs "active AND (admin OR owner) with their last order", you must drop to QueryBuilder. Design handlers so the criteria path is an optimisation, not a contract.
Hydration mode surprises. matching() returns a Collection of managed entities; DQL with ->getArrayResult() returns nested arrays. Never let a handler's return type drift between the two — downstream code that calls $user->getName() breaks silently on arrays, and tests with fixtures may not catch it if you assert with ==.
Long-running workers and stale identity maps. In a Messenger consumer or a queue worker, EntityManager is not reset between messages. Call $em->clear() after each query batch, or you'll serve stale entities and leak memory. This bites every Doctrine bridge and this one offers no protection.
N+1 is on you. matching() with an association-backed collection will lazy-load per entity. If a handler serialises entities to JSON, add an explicit leftJoin/addSelect query instead — you cannot fix it from the criteria object.
Debugging. Log $qb->getDQL() and the bound parameters, or enable doctrine.dbal.logging / the Symfony profiler's Doctrine panel. For matching() calls there's no QueryBuilder to inspect, so use Doctrine\DBAL\Logging\Middleware to capture the generated SQL — useful when a criteria filter silently produces a full table scan.
Extension points, ranked. (1) Decorate handlers via the bus — safest. (2) Subclass the criteria translator to add custom filter types (date ranges, LIKE, JSON containment) — moderate. (3) Patch the base handler — last resort, since you now diverge from upstream that no longer exists anyway, which is fine once you've forked.
Version pinning is mandatory. With no releases and no dependents, a force-push or branch deletion can silently change what dev-master resolves to. Pin to a commit SHA and mirror the repo internally.
How can I help you explore Laravel packages today?