PHPackages                             taranovegor/searcher-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. taranovegor/searcher-bundle

ActiveLibrary

taranovegor/searcher-bundle
===========================

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

v0.1.0(1mo ago)07↓66.7%MITPHPPHP &gt;=8.4

Since Jul 18Pushed 1mo agoCompare

[ Source](https://github.com/taranovegor/searcher-bundle)[ Packagist](https://packagist.org/packages/taranovegor/searcher-bundle)[ RSS](/packages/taranovegor-searcher-bundle/feed)WikiDiscussions master Synced 2w ago

READMEChangelog (1)Dependencies (12)Versions (2)Used By (0)

taranovegor/searcher-bundle
===========================

[](#taranovegorsearcher-bundle)

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
------------

[](#requirements)

- PHP &gt;= 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
-------

[](#install)

```
composer require taranovegor/searcher-bundle
```

```
// config/bundles.php
return [
    Taranovegor\SearcherBundle\SearcherBundle::class => ['all' => true],
];
```

Quick start
-----------

[](#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
------------------------

[](#query-string-conventions)

### Filters

[](#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

[](#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
-------------------

[](#definition-features)

### Renaming: API field vs. property

[](#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

[](#input-transformers)

Normalize a client value before validation and execution:

```
$config->addFilter('code', [FilterOperator::Eq])
    ->setInputTransformer(UppercaseTransformer::class); // FilterInputTransformerInterface, or a closure
```

### Filter handlers

[](#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

[](#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 )`. 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
--------------------

[](#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
--------------------------

[](#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
-----------

[](#development)

```
composer install
composer test     # phpunit
composer phpstan  # static analysis
composer phpcs    # coding standard
composer check    # all of the above
```

License
-------

[](#license)

MIT, see [LICENSE](LICENSE).

###  Health Score

38

—

LowBetter than 82% of packages

Maintenance90

Actively maintained with recent releases

Popularity6

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity41

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

46d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/4e8511596b0f666ab1ce311e5aa92ef5711b7401cac283b4e73681ee762c0e8f?d=identicon)[taranovegor](/maintainers/taranovegor)

---

Top Contributors

[![taranovegor](https://avatars.githubusercontent.com/u/16158349?v=4)](https://github.com/taranovegor "taranovegor (1 commits)")

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP\_CodeSniffer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/taranovegor-searcher-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/taranovegor-searcher-bundle/health.svg)](https://phpackages.com/packages/taranovegor-searcher-bundle)
```

###  Alternatives

[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k18.7M446](/packages/easycorp-easyadmin-bundle)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.4k1.4M244](/packages/sulu-sulu)[symfony/framework-bundle

Provides a tight integration between Symfony components and the Symfony full-stack framework

3.6k263.2M12.8k](/packages/symfony-framework-bundle)[drupal/core-recommended

Locked core dependencies; require this project INSTEAD OF drupal/core.

7544.4M464](/packages/drupal-core-recommended)[contao/core-bundle

Contao Open Source CMS

1231.7M3.2k](/packages/contao-core-bundle)[shopware/core

Shopware platform is the core for all Shopware ecommerce products.

605.9M718](/packages/shopware-core)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
