braune-digital/query-filter-bundle
Installation Add the bundle via Composer (Symfony 4+):
composer require braune-digital/query-filter-bundle
Register in config/bundles.php:
return [
// ...
BrauneDigital\QueryFilterBundle\BrauneDigitalQueryFilterBundle::class => ['all' => true],
];
Basic Configuration
Define a filter configuration in config/packages/braune_digital_query_filter.yaml:
braune_digital_query_filter:
filters:
product:
- { name: "category", type: "string", operator: "=", field: "category_id" }
- { name: "price_min", type: "numeric", operator: ">", field: "price" }
- { name: "price_max", type: "numeric", operator: "<", field: "price" }
First Use Case
Inject the QueryFilter service in a controller and apply filters:
use BrauneDigital\QueryFilterBundle\QueryFilter;
public function listProducts(Request $request, QueryFilter $queryFilter)
{
$query = $this->getDoctrine()->getRepository(Product::class)->createQueryBuilder('p');
$query = $queryFilter->apply($query, $request->query->all(), 'product');
return $this->render('product/list.html.twig', [
'products' => $query->getQuery()->getResult(),
]);
}
URL-Based Filtering
Use query parameters (e.g., ?category=electronics&price_min=50) to pass filters from frontend forms or client-side frameworks (React, Vue).
Form Generation Dynamically generate filter forms in Twig:
{% for filter in filters %}
{% if filter.type == 'string' %}
<input type="text" name="{{ filter.name }}" placeholder="{{ filter.name|replace('_', ' ') }}">
{% elseif filter.type == 'numeric' %}
<input type="number" name="{{ filter.name }}" placeholder="{{ filter.name|replace('_', ' ') }}">
{% endif %}
{% endfor %}
Repository Integration Extend repositories to include filtered queries:
public function findFiltered(QueryBuilder $qb, array $filters, string $filterGroup)
{
return $this->queryFilter->apply($qb, $filters, $filterGroup);
}
API Responses Return filtered results in API endpoints:
public function filterAction(Request $request)
{
$query = $this->getDoctrine()->getRepository(Product::class)->createQueryBuilder('p');
$filteredQuery = $this->queryFilter->apply($query, $request->query->all(), 'product');
return $this->json($filteredQuery->getQuery()->getResult());
}
Pagination + Filtering Combine with KnpPaginatorBundle:
use Knp\Component\Pager\PaginatorInterface;
public function listFiltered(Request $request, PaginatorInterface $paginator, QueryFilter $queryFilter)
{
$query = $this->getDoctrine()->getRepository(Product::class)->createQueryBuilder('p');
$filteredQuery = $queryFilter->apply($query, $request->query->all(), 'product');
$pagination = $paginator->paginate($filteredQuery, $request->query->getInt('page', 1), 10);
return $this->render('product/list.html.twig', ['pagination' => $pagination]);
}
Dynamic Filter Groups
Load filter configurations dynamically (e.g., from database) and pass them to the QueryFilter service.
Caching Filtered Queries
Cache filtered results with tags (e.g., product:category:electronics):
$filteredQuery = $queryFilter->apply($query, $filters, 'product');
$cacheKey = md5(serialize($filters));
return $this->cache->get($cacheKey, function() use ($filteredQuery) {
return $filteredQuery->getQuery()->getResult();
}, ['product_filters']);
Symfony Version Mismatch
Ensure you’re using the correct branch (1.4.x for Symfony 4/5). Mixing versions may break dependency resolution.
Case Sensitivity in Filters
By default, string filters are case-sensitive. Use operator: "LIKE" for case-insensitive searches:
- { name: "name", type: "string", operator: "LIKE", field: "name" }
Numeric Range Quirks
Ensure price_min and price_max are not passed together without values (e.g., ?price_min=). Add validation:
if ($request->query->has('price_min') && empty($request->query->get('price_min'))) {
$request->query->remove('price_min');
}
Doctrine Field Mismatches
If field in YAML doesn’t match the entity property (e.g., field: "category_id" vs. field: "category"), the filter will fail silently. Use dump($query->getSQL()) to debug.
Inspect Generated SQL Log the final query to verify filters are applied correctly:
$filteredQuery = $queryFilter->apply($query, $filters, 'product');
error_log($filteredQuery->getSQL());
Enable Query Logging
In config/packages/dev/doctrine.yaml:
doctrine:
dbal:
logging: true
profiling: true
Validate Filter Groups Always check if a filter group exists before applying:
if (!$queryFilter->hasGroup('product')) {
throw new \InvalidArgumentException('Filter group "product" not configured.');
}
Custom Operators
Extend the bundle by adding custom operators (e.g., IN, NOT IN):
use BrauneDigital\QueryFilterBundle\Operator\OperatorInterface;
class InOperator implements OperatorInterface
{
public function apply(QueryBuilder $qb, string $field, $value, string $alias)
{
return $qb->andWhere($alias . '.' . $field . ' IN (:value)')
->setParameter('value', $value);
}
}
Register in services.yaml:
services:
BrauneDigital\QueryFilterBundle\Operator\InOperator:
tags: { name: braune_digital_query_filter.operator, alias: 'in' }
Dynamic Filter Loading
Override the FilterLoader to load configurations from a database or API:
use BrauneDigital\QueryFilterBundle\Loader\FilterLoaderInterface;
class DatabaseFilterLoader implements FilterLoaderInterface
{
public function load(string $group): array
{
// Fetch from DB/API and return array of filters
return $this->entityManager->getRepository(FilterConfig::class)
->findBy(['group' => $group]);
}
}
Localization Support Translate filter names/placeholders in Twig:
<label>{{ 'filter.product.category'|trans }}</label>
<input type="text" name="category" placeholder="{{ 'filter.product.category_placeholder'|trans }}">
How can I help you explore Laravel packages today?