ibexa/core-search
Core search package for Ibexa DXP, providing the search engine abstraction, indexing, and query infrastructure used to power content search and filtering across the platform. Install as part of Ibexa DXP.
Do not install this package standalone. It is a component of Ibexa DXP; the supported path is composer create-project ibexa/ibexa-dxp (or adding ibexa/core-search alongside the rest of the metapackage). Confirm you actually have it with composer show ibexa/core-search before writing code, and confirm which backend package is installed alongside it — ibexa/solr and ibexa/elasticsearch are where engine-specific behavior lives, and that is where you will spend debugging time.
Look at these in order:
Ibexa\Contracts\Core\Repository\SearchService — the only API you should call from application code (findContent, findContentInfo, findSingle, supports).Ibexa\Contracts\Core\Repository\Values\Content\Query — Query, LocationQuery, Criterion\*, SortClause\*, FacetBuilder, Aggregation\*. These are plain, mutable value objects; there is no builder DSL to learn.SearchResult / SearchHit — $result->totalCount, $result->searchHits[].valueObject, $result->searchHits[].score, $result->aggregations, $result->facets, $result->timedOut.ibexa/solr schema.xml/solrconfig.xml or the Elasticsearch index templates) when a query returns nothing and you suspect the field is not indexed.Configuration is per siteaccess, not per bundle:
# config/packages/ibexa.yaml
ibexa:
system:
default:
search:
engine: solr # or elasticsearch / legacy
connection: default
# config/packages/ibexa_search.yaml (Solr connection details)
ibexa_search:
connections:
default:
entry_endpoint: '%env(SOLR_DSN)%'
Your first use case: inject the service and run a location query.
use Ibexa\Contracts\Core\Repository\SearchService;
use Ibexa\Contracts\Core\Repository\Values\Content\LocationQuery;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\SortClause;
final class LatestArticles
{
public function __construct(private readonly SearchService $searchService) {}
public function __invoke(): array
{
$query = new LocationQuery([
'filter' => new Criterion\LogicalAnd([
new Criterion\ParentLocationId(2),
new Criterion\ContentTypeIdentifier(['article']),
new Criterion\Visibility(Criterion\Visibility::VISIBLE),
]),
'sortClauses' => [new SortClause\DatePublished(LocationQuery::SORT_DESC)],
'limit' => 10,
]);
return $this->searchService->findContent($query)->searchHits;
}
}
Before trusting any result, make sure the index is populated: run bin/console ibexa:reindex --help and do a full reindex on a fresh environment. An empty index returns totalCount: 0 with no error — the single most common "the API is broken" false alarm.
Location queries for hierarchical/listing pages, content queries for cross-cutting search. LocationQuery is cheaper for "children of X in subtree" pages because the backend can constrain on the location path. Use ContentQuery only when results must not be location-scoped (global search, related-content, dedup across locations). Remember a single content item can appear multiple times in a LocationQuery result — dedupe by contentInfo->id if the UI cannot tolerate duplicates.
Scoring lives in query, not filter. Query::$filter is for non-scoring narrowing; Query::$query is the criterion that contributes to relevance. Putting Criterion\FullText in filter "works" but yields score 0 everywhere and makes SortClause\Score meaningless. For search pages, put FullText in query and your taxonomy/visibility constraints in filter.
Facets vs aggregations. Facets ($query->facetBuilders) are for user-facing drill-down with counts; aggregations ($query->aggregations, e.g. ContentTypeTermAggregation, RawTermAggregation) are for building filter sidebars and dashboards where you need structured values rather than facet buckets. Names are keys — you retrieve them by the same string you registered, and duplicate names silently shadow each other. Guard both with $searchService->supports(...) (capability classes under Ibexa\Contracts\Core\Search\Capability) if you support more than one engine.
Editorial-driven listings belong in content view config, not PHP. For "front page curated list" and similar, use the built-in query controller in your content view YAML with query_type: Ibexa\Contracts\Core\Repository\Values\Content\Query (or LocationQuery) and pass criterion/sort_clauses/limit as parameters. That keeps cache and view configuration in one place and lets integrators tweak it without touching code. Reserve hand-written SearchService calls for logic that genuinely needs runtime state.
Pagination: use the Pagerfanta adapters. Ibexa\Core\Pagination\Pagerfanta\ContentSearchAdapter and LocationSearchAdapter wrap a Query and translate the page into limit/offset for you. Do not hand-roll offset math against totalCount — deep offsets are the fastest way to make a Solr page slow.
Never mutate a shared Query. Query and its criteria are mutable objects. If a factory service returns a query and a controller appends a limit, the next caller inherits it. Build a fresh query per request, or clone before modifying.
Custom criteria are a two-part job. You declare the value object in your own code (extending Criterion), implement the handler inside the backend adapter (Solr/Elasticsearch), and register it with that backend's DI tag. This means a custom criterion is not portable for free: it must exist for every engine you deploy, or be behind a supports() check with a fallback (usually a Criterion\Field or Criterion\ContentId IN list computed in PHP).
Indexing is driven by the platform, not by you. Publish/delete events trigger index updates automatically. Add custom searchable data in this order of preference: (1) a searchable FieldDefinition on the content type — zero code; (2) a custom Field Type plus an index field mapper in the backend package; (3) a repository event listener that queues a targeted reindex. Reach for (3) only when (1) and (2) are genuinely impossible; it is the pattern most likely to drift out of sync with content changes.
Reindex on every schema-affecting change. New field mapper, changed analyzer, new synonym file, new content type attribute marked searchable — all require a full reindex in every environment, including CI fixtures. Treat the index as a derived artifact and put reindex in the deploy script, not in a wiki page.
findContent($query, $languageFilter, $filterOnUserPermissions = true) removes items the current user cannot read. Disabling it to "make results show up" is how private content leaks into caches; if you disable it for a public listing, be certain the criteria already exclude everything non-public, and do not share that response cache with logged-in users.new Criterion\LogicalAnd([]) is an invalid query. Guard with a helper that returns null for an empty criterion list and only appends the LogicalAnd when you have at least one child — this is the most common crash when filters are all optional.Criterion\Field('sku', Criterion\Field::EQ, 'X') finds nothing but the content exists, check the Field Definition's isSearchable flag and whether the attribute is actually in the index mapping. Verify in the Solr admin UI / ES index directly rather than debugging through PHP.content_type_id, section_id, user_metadata, and per-field-type suffixed names (*_s, *_t, *_id) differ per backend. Copy them from the schema, never guess, and expect them to differ between Solr and Elasticsearch.findContent is a language filter array (languages, useAlwaysAvailable, useContentLanguage). With useAlwaysAvailable => false, untranslated items vanish from fallback-language siteaccesses. Set it explicitly per siteaccess rather than relying on defaults.max_result_window (default 10,000) will either slow down or hard-fail. Cap page depth in the UI, narrow with filters, and prefer cursor-style "next page" flows over page-number pickers for large result sets.findContent hydrates full Content objects; findContentInfo does not. For listings that only render names and images, findContentInfo/findSingleContentInfo (where available) and lazy-loading via the repository is meaningfully cheaper at 50+ rows. Same for LocationQuery — you get Location value objects, not content.$searchService->supports(...) for facets, aggregations, suggestions, and spellcheck lets one codebase run on Solr in production and the legacy/in-memory engine in tests without silently degrading. Where a capability is missing, implement a PHP-side fallback (e.g. compute counts from totalCount of small filtered queries) — that is also your escape hatch when a client insists on a different engine.totalCount with an empty filter — is anything indexed at all? (2) add criteria one at a time; (3) inspect the generated backend query (the backend package logs it at debug level — enable the ibexa/ibexa_search channels in Monolog); (4) query the engine directly. Almost every "search bug" is an indexing or configuration bug, not a query-construction bug.ibexa/core-search in composer.json.How can I help you explore Laravel packages today?