Search by

taranovegor / searcher-bundle

taranovegor

Filter, sort and paginate search component for Symfony applications, decoupled from any single persistence backend.

Package info

github.com/taranovegor/searcher-bundle

pkg:composer/taranovegor/searcher-bundle

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-07-18 15:02 UTC

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.