gregosphatos/doctrine-filters-bundle
A Symfony bundle for Doctrine that lets you register and enable SQL filters declaratively through configuration, so you can apply reusable query conditions such as soft-delete or tenant scoping without touching repository code.
This is a Symfony bundle, not a Laravel package — there is no ServiceProvider, no config/*.php to publish, and no facade. Treat it as a source of Doctrine SQLFilter implementations plus configuration conventions you'll probably re-implement by hand.
Prerequisites before anything else:
# You need Doctrine ORM in Laravel first; Eloquent won't help you here
composer require laravel-doctrine/orm
composer require gregosphatos/doctrine-filters-bundle
Where to look first, in this order:
src/Filter/ — the actual filter classes. This is the only part that matters to a Laravel app.DependencyInjection/ and any *Extension.php — shows default filter names, which filters are registered, and what parameters each expects.Resources/config/ — service wiring and the config tree, which tells you how the author intended filters to be enabled.First use case to validate the package is worth keeping: pick the simplest filter it ships (typically a "deleted/hidden rows" or tenant-scoping filter), register it manually against Doctrine's Configuration, and confirm it constrains a SELECT:
// config/doctrine.php or a service provider
$config->addFilter('soft_deleted', \GreGosPhaTos\DoctrineFilters\Filter\SoftDeletedFilter::class);
$em->getFilters()->enable('soft_deleted');
$results = $em->getRepository(Post::class)->findAll(); // should now exclude hidden rows
If that two-liner works, the filters are usable. If you find yourself porting DI extension code, stop and just write the filter yourself — it's a ~30-line class.
Register once, enable per context. Registration is global (Configuration::addFilter), but enabling is per-EntityManager and mutable. Make enabling an explicit, request-scoped decision rather than a constructor side effect.
final class DoctrineFilterManager
{
public function __construct(private EntityManagerInterface $em) {}
public function enable(string $name, array $params = []): void
{
$filters = $this->em->getFilters();
if (! $filters->isEnabled($name)) {
$filters->enable($name);
}
if ($params) {
$filters->getFilter($name)->setParameters($params); // or setParameter() per key
}
}
public function disable(string $name): void
{
$this->em->getFilters()->disable($name);
}
}
Tenant scoping via middleware is the canonical day-to-day use. Enable after the tenant is resolved, and always reset on the way out so Octane/queue workers don't leak state:
public function handle($request, Closure $next)
{
$this->filters->enable('tenant', ['tenant_id' => tenant()->id]);
try {
return $next($request);
} finally {
$this->filters->disable('tenant');
}
}
Escape hatches for admin/CLI work. Any code path that must see all rows — reporting jobs, artisan commands, admin panels, migrations — needs the filter off. Wrap these in a small helper so nobody forgets:
function without_filters(callable $fn, array $names): mixed
{
foreach ($names as $n) { $em->getFilters()->disable($n); }
try { return $fn(); } finally { foreach ($names as $n) { $em->getFilters()->enable($n); } }
}
Repositories as the boundary. Put enable/disable logic in repository methods rather than scattering it through controllers. A filter that's toggled in three places will eventually be toggled inconsistently, and you'll get a "why does this query return 4 rows here and 6 there" bug.
Testing pattern. In PHPUnit, enable filters in setUp() per test class, not globally — global filter state is the #1 source of order-dependent test failures in Doctrine suites. If the bundle's filters key off entity metadata (e.g. a deletedAt column), assert both the enabled and disabled result sets in the same test.
Symfony-to-Laravel porting. If the bundle's DI extension does anything non-trivial (per-entity opt-in, annotation/attribute-driven config), reproduce it as a Configuration::addFilter call plus a static registry in your own code. Don't try to boot the Symfony container.
It is not a Laravel package. Zero service providers, zero Laravel integration, zero dependents. You are the first person to try this combination in all likelihood. Budget time for reading source, and vendor it or fork it rather than depending on upstream fixes.
Filters only apply where Doctrine generates SQL from DQL. Native SQL (EntityManager::getConnection()->executeQuery(), DBAL query builders, raw DB::select) bypasses filters entirely. If your app mixes DBAL and ORM, your tenant isolation has a hole. Audit for getConnection() usage before trusting a tenancy filter.
Bulk DML is a known weak spot. Verify behaviour of QueryBuilder::update() / delete() and EntityManager::createQuery('DELETE ...') with filters enabled. Don't assume a soft-delete filter prevents a bulk DELETE from touching hidden rows.
Parameter handling is not automatic. setParameter() binds safely; string-concatenating a tenant ID into addFilterConstraint() is a SQL injection vector. Filters are exactly the place where "it's just an int, it's internal" turns into a CVE.
Missing parameters throw at query time, not at enable time. The failure surfaces deep in a query, often as a InvalidArgumentException from SQLFilter::getParameter(). Set parameters before enabling, or set both atomically.
State leaks in long-running processes. Octane, Horizon workers, and messenger consumers reuse the EntityManager. A filter enabled for tenant A will happily filter tenant B's queries. Always disable() in a finally block, and consider resetting the EntityManager between jobs.
Debugging: look at the generated SQL, not the DQL. Use Laravel's query log, Doctrine\DBAL\Logging\DebugStack, or $query->getSQL() to confirm the filter constraint actually landed in the WHERE. A typo in the filter name (enable('soft_delete') vs 'soft_deleted') fails silently in some Doctrine versions rather than throwing.
Watch out for cache interactions. Query result cache and second-level cache can serve rows that the filter would have excluded if the cache was warmed in a different filter state. Either disable result caching for filtered queries or include the filter state in the cache key.
Extension points, in order of preference: (1) subclass the bundle's filter and swap the class in addFilter(); (2) write a fresh SQLFilter — the interface is tiny; (3) fork the bundle if you need upstream changes. Given 0 stars and 0 dependents, option (2) is often the honest answer.
Pin the version. MIT license is fine, but with no community and no dependents, an unannounced rewrite of a filter class breaks you silently. Use an exact constraint ("1.0.0", not "^1.0") or a VCS repository pointing at a commit/tag in your own fork.
How can I help you explore Laravel packages today?