Search by

wexample / symfony-search

weeger

Cross-cutting search for Symfony: entities, pages, and anything else worth finding

Package info

github.com/wexample/symfony-search

pkg:composer/wexample/symfony-search

Statistics

Installs: 8

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

4.0.0 2026-09-17 10:38 UTC

This package is auto-updated.

Last update: 2026-09-17 10:38:40 UTC


README

Version: 4.0.0

wexample/symfony-search answers one question — what, here, is called this? — across things that have nothing in common: rows in a table, pages in a router, and whatever else a host application decides to make findable.

Installation

composer require wexample/symfony-search

Then register the bundle in config/bundles.php:

Wexample\SymfonySearch\WexampleSymfonySearchBundle::class => ['all' => true],

Making an entity findable

The class says it is findable; the properties say on what.

#[ORM\Entity(repositoryClass: InvoiceRepository::class)]
#[Searchable(contexts: [AppSearchContext::HEADER], route: 'app_invoice_show')]
class Invoice extends AbstractEntity
{
    #[ORM\Column]
    #[SearchText]
    protected string $label;

    #[ORM\Column]
    #[SearchReference(points: 30)]
    protected string $reference;

    #[ORM\Column]
    #[SearchAmount(points: 40)]
    protected float $priceTotal;
}

Each attribute says what the property is, and the kind is the whole point: it decides the SQL — a title is searched with LIKE, an amount with = on its absolute value, an address whole — and the points, through the matching method of src/Class/SearchScore.php. A kind that does not fit the query's shape adds no clause and no points: an amount is not asked dupont, a title is not asked 15428. So 15428 finds the invoice of 15428 before the log whose id happens to be 15428.

The kinds shipped, each with its default points: SearchText, SearchReference, SearchAmount, SearchEmail, SearchId, under src/Attribute/.

Declared on the property, the field travels with a renaming, and a trait bringing a column may bring the way it is searched with it — a trait of the application, or of a package allowed to name this one.

wexample/symfony-helpers is not allowed to: the api requires it and this package requires the api, so the dependency would close a circle, and the suite's dependency check refuses the import outright. Its traits — HasTitleTrait, HasNameTrait, HasBodyTrait — hold the columns most entities are searched on, so those are named on the class:

#[Searchable(fields: ['name', 'body' => new SearchText(points: 5)])]

A name alone takes the kind its column implies: a string or a text is read, a decimal or a float is a figure. Anything else has to say what it is — a date, a boolean, an enum say nothing about how they would be searched, and the registry refuses to guess rather than guess wrong. Naming the kind is also how the default points are changed, as body does above.

Taking a field back

An entity using a trait it did not write may refuse what the trait declared, either where the reader of the entity will see it:

#[SearchIgnore]
#[ORM\Column]
protected ?string $title = null;

or on the class, for the entity that would rather not redeclare the property:

#[Searchable(except: ['title'])]

When the points are not a list

Six of the legacy's seven scoring functions were a field list in disguise. The seventh halved its points in the header and doubled them on one field, and that one names a class:

#[Searchable(scoring: TransactionSearchScoring::class)]

wex app::state/rectify writes src/Search/TransactionSearchScoring.php, implementing src/Interface/SearchScoringInterface.php. It is handed the same builder the declared fields go through, so it reads like the attribute would:

public function score(SearchQuery $query, AbstractEntity $entity, SearchScore $score): void
{
    $score
        ->text($entity->getDescription())
        ->amount($entity->getAmount(), points: 80)
        ->email($entity->getContact()?->getEmail())
        ->inContext(SearchContext::HEADER, factor: .5);
}

Each method keeps the guard its kind needs — ->amount() says nothing to a word, ->email() nothing to a fragment — so the class never asks is_numeric() itself. The attributes on the properties still say what the SQL looks in; the class says what it is worth. A scoring class is a service: it may take the security or anything else in its constructor.

Asking

$results = $searchService->search(
    new SearchQuery('dupont', AppSearchContext::HEADER, maxResults: 10)
);

Over HTTP, the results are the collection of one entity like any other:

GET /api/search-result/list?search=dupont&context=header&length=10

type restricts the answer to one kind — ?type=invoice for one entity, ?type=route for pages only.

When the entity has to reason

#[Searchable] covers the entity whose answer is a field list. The one whose answer depends on who is asking — the legacy restricted its user list to other members, or to the user alone — adds provider: true and gets a class:

#[Searchable(fields: ['username', 'email'], provider: true)]
class User extends AbstractEntity

wex app::state/rectify then writes src/Search/UserSearchProvider.php, extending src/Class/Provider/AbstractEntitySearchProvider.php. Only configureQuery() is left to fill:

protected function configureQuery(QueryBuilder $builder, SearchQuery $query): void
{
    $builder->andWhere(EntitySearchRunner::ALIAS.'.active = true');
}

The attribute stays on the entity, and everything in it — fields, title, route, weight — is still what the provider searches with. What provider: true changes is only who runs the query: the generic provider steps aside, because both answering would show every record twice.

Searching something that is not an entity

Implement src/Interface/SearchProviderInterface.php — usually by extending src/Class/Provider/AbstractSearchProvider.php, which reads the key off the class name and handles supports(). Autoconfiguration does the registration: a provider joins the search by existing.

class CommandSearchProvider extends AbstractSearchProvider
{
    public function provide(SearchQuery $query): iterable
    {
        foreach ($this->application->all() as $name => $command) {
            $score = SearchScoreHelper::fieldsScore($query->terms, [$name, $command->getDescription()]);

            if ($score > 0) {
                yield $this->createResult($name, $name)->setScore($score);
            }
        }
    }
}

The same road is the one to take for an entity whose visibility depends on who is asking: #[Searchable] covers the entities that do not reason, and a provider written by hand covers the ones that do.

The front

The bundle ships its own section — src/Controller/Pages/SearchController.php and assets/pages/search/ — which reads the application it runs in rather than describing itself: the providers come from the tag, the searchable entities from the attributes they carry, the endpoint from the router. An application lists it with one line in its layout:

{{ menu_item_collapsible_from_controller(render_pass, 'Wexample\\SymfonySearch\\Controller\\Pages') }}

A search box is dynamic through and through, so it is a Vue component — assets/vue/search/search-box.vue — and so is the row it draws for each result, assets/vue/search/search-result.vue. They live here rather than in wexample/symfony-design-system, because they know this package's API: the box asks the searchResult repository generated from src/Entity/SearchResult.php and sends it search, context and type. What they borrow from the design system is its vocabulary — the bar partial a row is drawn as — which is the part that knows nothing of searching. The host application registers the repository on its API client:

protected getRepositoryClasses() {
  return [...ownRepositories, ...searchRepositories];
}

Dropped anywhere in a template:

{{ vue(render_pass, '@WexampleSymfonySearchBundle/vue/search/search-box', { context: 'header', length: 8 }) }}

One row per kind of result

The box resolves the row from the result's type: a component registered as search-result-<type> draws it, the default row draws everything else — pages included, which is why nothing has to be declared for them. An application that wants its invoices to look like invoices extends the box and registers the rows it adds, one Vue component per entity, each with its .vue.twig required and its .scss:

export default {
  extends: SearchBox,
  template: '#vue-template-app-vue-search-app-search-box',
  components: { SearchResultInvoice, SearchResultContact }
};

A row extends search-result and overrides what differs — an icon, the label of its type, a block of the template. wexample/symfony-design-system-demo does exactly this for its two entities, and the showcase's header uses that box rather than the bare one.

Arrows, Home, End and Enter walk the rows through the keyboard service of the loader, so a dropdown and a modal open at once never both answer the same key; Escape closes; a click outside is told by the overlay service. A result whose kind declared no route carries no url: its row stands as a plain block rather than a link.

Which pages are found

A page is a route served by a controller extending AbstractPagesController — the same test the menu builder makes — reachable by GET without parameters, and open to the current user by #[IsGranted]. Its title is the page_title its own translations declare, the one the menu and the tab show; a route option search_title stands in when there is none, and the route name humanised when there is neither. options: ['searchable' => false] hides a page that has no business being found.

In a form

src/Form/Type/EntitySearchInputType.php is a field whose value is a record picked by searching for it:

->add('room', EntitySearchInputType::class, [
    EntitySearchInputType::OPTION_ENTITY_TYPE => 'demo_room',
    'placeholder' => true,
])

What the form carries is an identifier, in a hidden input like any other field; what the person sees is the search box narrowed to that one kind, then the picked record. The rows do not link there — picking is the point — and the context defaults to form_field, which is what lets an entity be findable in a picker without appearing in the header.

The field lives here and not in wexample/symfony-forms, because it offers this package's feature and would have nothing to show without it. src/Resources/config/ is not where it is declared: assets/form/form_theme.html.twig holds its one block and the extension prepends it to twig.form_themes, which Twig merges — so a bundle brings the fields it offers rather than the form package carrying fields whose feature it does not ship.

Configuration## Configuration

# config/packages/wexample_symfony_search.yaml
wexample_symfony_search:
    # Who may search at all. ROLE_ANONYMOUS opens it to everyone.
    minimum_role: ROLE_USER
    # How many results a query asking for no number gets.
    max_results: 5

Hiding a page from the search

A route is a page unless it says otherwise:

#[Route(path: '/legal', name: 'app_legal', options: ['searchable' => false])]

And ['search_title' => 'Legal notice'] names it, for as long as nothing translates route names.

Table of Contents

Architecture

One query, several families of findable things, one sorted list out. The families do not know each other and the caller does not know them either — it asks src/Service/SearchService.php and reads src/Entity/SearchResult.php back.

The parts

src/WexampleSymfonySearchBundle.php extends AbstractBundle from wexample/symfony-helpers and implements PseudocodeBundleInterface, which is what lets the entity of this bundle be exported to TypeScript from the application that installs it.

src/DependencyInjection/WexampleSymfonySearchExtension.php loads the services and does one thing worth knowing: it calls registerForAutoconfiguration on src/Interface/SearchProviderInterface.php. A provider written in a host application is tagged by implementing the interface, so nothing has to name the tag — and no compiler pass has to walk the definitions, which is what wexample/symfony-routing needs only because it keys off an attribute instead.

src/Class/SearchQuery.php is the question: terms, context, how many results, and optionally one type to restrict to. It is a value, built by a controller, a form field or a test alike. Two things happen in its constructor and nowhere else — the terms are trimmed, and the context is turned from a backed enum into the string it carries.

src/Entity/SearchResult.php is the answer. See below.

src/Service/SearchService.php asks every provider that supports() the query, cuts each provider's answers to the asked number, merges, sorts and cuts again.

Providers

src/Class/Provider/AbstractSearchProvider.php reads the provider's key off its own class name — RouteSearchProvider answers with route — so that the key, the class and the type a client asks for are one thing rather than three to keep in agreement.

src/Class/Provider/EntitySearchProvider.php covers every entity carrying src/Attribute/Searchable.php, read from Doctrine's metadata by src/Service/SearchableRegistry.php, which resolves each one's fields by reflecting its properties — getProperties() reports what a trait brought as if the class had written it, which is what lets a trait carry a column and the way it is searched together. One provider for all of them, because what differed between the legacy's seven *SearchService classes was a field list, and a field list is data. Its results carry the entity as their type, not the provider — a client asking for invoice gets invoices and never learns who found them.

src/Service/EntitySearchRunner.php is the query and the mapping, held apart because two providers need it and neither owns it: the generic one runs it over every entity that declared no provider, and src/Class/Provider/AbstractEntitySearchProvider.php runs it over its single entity after configureQuery() has narrowed the builder. That second road is the one the legacy's UserSearchService needed, and the entity taking it says so with provider: true — which is also what keeps the generic provider from answering alongside it and returning every record twice.

src/Class/Provider/RouteSearchProvider.php answers with pages, which are routes. Nothing is indexed: the router already holds the list. What it decides is which route is a page someone could be looking for, and that decision is four refusals — the internals (_-prefixed and /api/), anything that is not a GET, anything that cannot be linked to without data, and anything #[IsGranted] closes to the current user. A route protected by a firewall pattern or by a check inside the action is not seen from here.

Why the result is an entity with no ORM mapping

SearchResult extends AbstractEntity and carries no #[ORM\Entity]. Doctrine skips a class that does not declare itself an entity, so nothing is mapped, nothing is persisted, and no table exists. What is gained is everything being an entity brings on the way out: #[PseudocodeExport] produces SearchResult.ts and its repository, and a collection of results is a collection of entities like any other, which the front already renders.

That is also why the Has*Trait of symfony-helpers are not used for its fields — they carry #[Column], and a column is exactly what none of these fields has.

Identity is Uuid::v5 over the type and the reference, so the same thing found twice is the same result twice, in another request or another process.

Filtering is SQL, ranking is PHP

A field is typed — src/Attribute/AbstractSearchField.php and its five kinds, which are the attributes written on the properties — and the type answers both sides: constrain() gives the SQL clause for the query's shape, or null when that shape cannot be looked for in this kind of field; score() says the points to src/Class/SearchScore.php. The builder is what an entity's scoring class receives too, so the declarative list and the hand-written class are one vocabulary at two levels rather than two formats. A query whose shape fits none of the declared fields runs no SQL at all: an unconstrained query would have returned the table.

src/Helper/SearchScoreHelper.php is the engine under the builder, and runs after the query, on the rows it returned. Expressing "the needle is a whole word, near the start, accents aside" as joins is the shape the legacy avoided and this package avoids too.

The consequence is EntitySearchProvider::FETCH_FACTOR: the database is asked for twice what will be shown, because it cannot order by a score that does not exist yet. Widening the window is not fixing it — a term matching a thousand rows still ranks only the first two hundred the database happened to return. That is the known ceiling of the design, and the reason a full-text engine would be a provider rather than a patch.

stringScore() tries three ways of making two strings meet — as written, ignoring case, then ignoring accents — and the first that answers wins rather than adding to the others. The needle is preg_quoted before the word-boundary test; the legacy interpolated it raw, so a query containing ( scored nothing and emitted a warning.

Boundaries

The DQL is built here, but the query helpers it could have used — querySearchLike, querySearchNumber, querySelectEntity — stay in SearchableRepositoryTrait in wexample/symfony-helpers, for the repository that wants to write its own provider. Numeric columns reached by LIKE are not handled: the declared fields are expected to hold text.

Integration in the Suite

This package is part of the Wexample Suite — a collection of high-quality, modular tools designed to work seamlessly together across multiple languages and environments.

Related Packages

The suite includes packages for configuration management, file handling, prompts, and more. Each package can be used independently or as part of the integrated suite.

Visit the Wexample Suite documentation for the complete package ecosystem.

Dependencies

  • php: >=8.5
  • wexample/php-helpers: >=4.0.0
  • wexample/php-pseudocode: >=2.1.0
  • wexample/symfony-helpers: >=8.0.0
  • wexample/symfony-api: >=5.0.0
  • wexample/symfony-pseudocode: >=3.0.0
  • symfony/uid: >=6.2
  • symfony/routing: >=6.2
  • symfony/security-bundle: >=6.2
  • doctrine/orm: >=2.14
  • wexample/symfony-loader: >=6.0.0
  • wexample/symfony-design-system: >=11.0.0
  • wexample/symfony-content: >=1.0.0
  • wexample/symfony-forms: >=6.0.0

Versioning & Compatibility Policy

Wexample packages follow Semantic Versioning (SemVer):

  • MAJOR: Breaking changes
  • MINOR: New features, backward compatible
  • PATCH: Bug fixes, backward compatible

We maintain backward compatibility within major versions and provide clear migration guides for breaking changes.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Free to use in both personal and commercial projects.

About us

Wexample stands as a cornerstone of the digital ecosystem — a collective of seasoned engineers, researchers, and creators driven by a relentless pursuit of technological excellence. More than a media platform, it has grown into a vibrant community where innovation meets craftsmanship, and where every line of code reflects a commitment to clarity, durability, and shared intelligence.

This packages suite embodies this spirit. Trusted by professionals and enthusiasts alike, it delivers a consistent, high-quality foundation for modern development — open, elegant, and battle-tested. Its reputation is built on years of collaboration, refinement, and rigorous attention to detail, making it a natural choice for those who demand both robustness and beauty in their tools.

Wexample cultivates a culture of mastery. Each package, each contribution carries the mark of a community that values precision, ethics, and innovation — a community proud to shape the future of digital craftsmanship.

Migration Notes

When upgrading between major versions, refer to the migration guides in the documentation.

Breaking changes are clearly documented with upgrade paths and examples.