taranovegor / searcher-bundle
Filter, sort and paginate search component for Symfony applications, decoupled from any single persistence backend.
Requires
- php: >=8.4
- psr/container: ^1.1 || ^2.0
- symfony/config: ^6.4 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/validator: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- doctrine/dbal: ^3.0 || ^4.0
- doctrine/orm: ^2.0 || ^3.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- squizlabs/php_codesniffer: ^3.0
- symfony/cache: ^6.4 || ^7.0 || ^8.0
Suggests
- doctrine/orm: Required to use the Doctrine ORM adapter (Taranovegor\SearcherBundle\Doctrine\*)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-18 15:31:47 UTC
README
Filter, sort and paginate search component for Symfony applications, decoupled from any single persistence backend.
GET /tasks?filter[status]=in:backlog,in_progress&sort=-id&limit=20&offset=0
A SearchDefinition declares what a search is allowed to do — which fields are
filterable and with which operators, which are sortable, how pagination is
bounded. Everything a client sends that the definition did not declare is
dropped, so the query string can never reach further into your schema than you
explicitly allowed.
Requirements
- PHP >= 8.4
- Symfony 6.4 / 7.x / 8.x
doctrine/orm^2.0 || ^3.0 — only if you use the Doctrine adapter (Taranovegor\SearcherBundle\Doctrine\*); the bundle registers it automatically when Doctrine ORM is installed
Install
composer require taranovegor/searcher-bundle
// config/bundles.php return [ Taranovegor\SearcherBundle\SearcherBundle::class => ['all' => true], ];
Quick start
Define what a search is allowed to do:
use Symfony\Component\Validator\Constraints as Assert; use Taranovegor\SearcherBundle\Configurator\SearchConfigurator; use Taranovegor\SearcherBundle\Doctrine\DoctrineSearchableDefinitionInterface; use Taranovegor\SearcherBundle\Enum\FilterOperator; final class TaskSearchDefinition implements DoctrineSearchableDefinitionInterface { public function getEntityClass(): string { return Task::class; } public function configure(SearchConfigurator $config): void { $config->addFilter('status', [FilterOperator::Eq, FilterOperator::In]) ->addConstraint(new Assert\Choice(choices: ['backlog', 'in_progress', 'done'])); $config->addFilter('title', [FilterOperator::Like]); $config->addSortable('createdAt'); $config->paginable(maxLimit: 100, defaultLimit: 20); } }
Bind a controller argument with #[MapSearch] and run the search:
use Taranovegor\SearcherBundle\Attribute\MapSearch; use Taranovegor\SearcherBundle\Dto\SearchQuery; use Taranovegor\SearcherBundle\SearcherInterface; #[Route('/tasks', methods: ['GET'])] public function list( #[MapSearch(TaskSearchDefinition::class)] SearchQuery $query, SearcherInterface $searcher, ): Response { $result = $searcher->search($query); // $result->getData() — matched entities // $result->getPagination() — limit/offset/total, or null when the // definition did not call paginable() }
Query string conventions
Filters
filter[field]=value equality (implicit eq)
filter[field]=gte:2025-01-01 explicit operator
filter[field]=gte:1;lte:9 several conditions on one field (AND)
filter[field]=in:a,b,c list values, comma-separated
filter[field][]=a&filter[field][]=b array form, treated as in
Operators: eq, neq, gt, gte, lt, lte, in, notIn, like
(like matches the value as a literal substring — %/_ in it are escaped).
A filter for an undeclared field is ignored. A condition using an operator the field was not declared with is ignored (and logged at notice level). A value failing one of the field's constraints rejects the whole request — the HTTP resolver turns that into a 422 response.
Sorting and pagination
sort=title ascending
sort=-createdAt descending (- prefix)
sort=-priority;title several fields, in order
limit=20&offset=40 pagination (clamped to the definition's maxLimit)
A missing or invalid limit falls back to the definition's defaultLimit.
Clients can never request an unbounded result set; unbounded queries are a
server-side capability (PaginationDetails::unlimited()).
Definition features
Renaming: API field vs. property
$config->addFilter('state', [FilterOperator::Eq])->setProperty('status'); $config->addSortable('name')->setProperty('title');
Clients use the API name; queries use the property. For the Doctrine adapter the property must be a scalar field of the root entity — filtering through a relation needs a filter handler.
Input transformers
Normalize a client value before validation and execution:
$config->addFilter('code', [FilterOperator::Eq]) ->setInputTransformer(UppercaseTransformer::class); // FilterInputTransformerInterface, or a closure
Filter handlers
Custom query logic — joins, computed expressions — for one filter:
$config->addFilter('tag', [FilterOperator::Eq]) ->setHandler(TagNameFilterHandler::class); // FilterHandlerInterface, or a closure
use Taranovegor\SearcherBundle\Context\FilterContextInterface; use Taranovegor\SearcherBundle\Definition\FilterHandlerInterface; use Taranovegor\SearcherBundle\Doctrine\DoctrineFilterContext; use Taranovegor\SearcherBundle\Enum\OperatorInterface; final class TagNameFilterHandler implements FilterHandlerInterface { public function __invoke(FilterContextInterface $context, OperatorInterface $operator, mixed $value): void { if (!$context instanceof DoctrineFilterContext) { throw new \InvalidArgumentException('This handler only supports the Doctrine backend.'); } $param = $context->uniqueParameterName('tag'); $context->join(sprintf('%s.tags', $context->getRootAlias()), 'tag') ->andWhere($context->expr()->like('tag.name', ":$param")) ->setParameter($param, "%$value%"); } }
The context exposes join(), leftJoin(), andWhere(), addOrderBy(),
expr(), setParameter() and uniqueParameterName(). Take bound-parameter
names from uniqueParameterName() so handlers cannot collide with each other
or with standard filters; alias joins after the field being filtered.
Deduplicating joined to-many filters
When a filter handler joins a to-many relation, the SQL result fans out to one
row per match. That inflates the reported total and breaks page windows:
LIMIT/OFFSET cut raw SQL rows before Doctrine's hydrator collapses
duplicates, so a page can come back short. Opt in to deduplication per
definition:
use Taranovegor\SearcherBundle\Doctrine\DistinctSearchableDefinitionInterface; final class StoreSearchDefinition implements DoctrineSearchableDefinitionInterface, DistinctSearchableDefinitionInterface { // ... }
The searcher then applies SELECT DISTINCT and counts
COUNT(DISTINCT <identifier>). It is not the default because SELECT DISTINCT
requires every ORDER BY expression to be part of the selected columns, which
conflicts with handlers ordering by a joined, non-selected expression.
Server-side criteria
Force criteria on top of whatever the client sent, without mutating the DTO:
use Taranovegor\SearcherBundle\Dto\SearchCriteriaDecorator; use Taranovegor\SearcherBundle\Model\FilterCondition; use Taranovegor\SearcherBundle\Enum\FilterOperator; $searchable = SearchCriteriaDecorator::wrap($query) ->withFilter(new FilterCondition('ownerId', FilterOperator::Eq, $user->getId())); $result = $searcher->search($searchable);
Extra filters are merged with the client's; server sorting (once set) replaces client sorting entirely; a pagination override replaces the client's pagination.
SearchResult::map() converts entities to response DTOs while keeping the
pagination metadata:
return $searcher->search($query)->map(TaskResponse::fromEntity(...));
Custom request conventions
SearchDtoValueResolver implements the filter[...]/sort/limit convention
above. To support a different one (e.g. flat ?status=x&cities[]=1 parameters),
extend AbstractSearchDtoResolver and override extractFilterParams();
validation, transformers and handlers are unaffected. Note that string values
still go through the operator:value / ; parsing — override scope is where
filter values come from, not their syntax.
Development
composer install composer test # phpunit composer phpstan # static analysis composer phpcs # coding standard composer check # all of the above
License
MIT, see LICENSE.