ARTICLE

Magento 2 Repositories and SearchCriteriaBuilder: Filters, Filter Groups, Sorting, and Paging With getList

Magento 2 Repositories and SearchCriteriaBuilder: Filters, Filter Groups, Sorting, and Paging With getList

In Magento 2, you query entities through a repository’s getList() method, passing a SearchCriteria object built with SearchCriteriaBuilder. Filters inside one filter group are joined with OR, separate groups are joined with AND, and sort orders plus page size and current page control the result set. getList() returns items and a total count.

That one paragraph covers the documented contract. The bugs come from the details around it: addFilter() quietly creating a new group on every call, pagination loops that never end, REST queries that cannot express the logic you want, and custom repositories that ignore filter groups altogether. This guide covers the contract, then the traps, with the code patterns that avoid them.

Why Magento uses repositories and search criteria

Repositories are part of Magento’s service contract layer. Interfaces such as ProductRepositoryInterface, OrderRepositoryInterface, and CustomerRepositoryInterface give you get, save, delete, and getList methods that stay stable across releases, while the collections and resource models underneath are treated as implementation details.

getList() is the search half of that contract. Instead of building a collection and calling addFieldToFilter() directly, you describe what you want with a SearchCriteria object and let the repository translate it. The same criteria structure is exposed in the REST API, which is why a query you write in PHP has a direct equivalent in a GET /V1/products?searchCriteria[...] call.

The payoff is stability and reuse. The cost is that the criteria language is deliberately limited, so it helps to know exactly where those limits are.

The building blocks

Adobe’s guide to searching with repositories breaks search criteria into four parts.

Component What it holds Builder class Typical call
Filter One field, one value, one condition type FilterBuilder setField('status')->setValue(1)->setConditionType('eq')
FilterGroup A list of filters joined with OR FilterGroupBuilder setFilters([$a, $b])
SortOrder A field and a direction, ASC or DESC SortOrderBuilder setField('created_at')->setDirection('DESC')
Paging Page size and current page SearchCriteriaBuilder setPageSize(100)->setCurrentPage(1)

SearchCriteriaBuilder assembles all of these and produces the final object when you call create().

A basic query looks like this:

$criteria = $this->searchCriteriaBuilder
    ->addFilter('status', 1)
    ->addFilter('type_id', 'simple')
    ->setPageSize(100)
    ->setCurrentPage(1)
    ->create();

$result = $this->productRepository->getList($criteria);
$items  = $result->getItems();
$total  = $result->getTotalCount();

The returned object implements a search results interface. Adobe’s documentation notes that the total count reflects all matching entities regardless of the paging set in the criteria, which is what you use to work out how many pages exist.

AND vs OR: the rule and the trap

The rule, in Adobe’s own wording, is that OR joins filters inside a single filter group and AND joins filter groups inside the search criteria. Their example is:

(url like %magento.com OR store_id eq 1) AND (url_type eq 1)

The trap is how the builder’s shortcut methods map onto that rule:

  • addFilter($field, $value, $condition) creates a new filter group containing one filter every time you call it. Three addFilter() calls produce three groups, so they are joined with AND.
  • addFilters([$filterA, $filterB]) puts both filters into one group, so they are joined with OR.
  • setFilterGroups([...]) replaces whatever groups the builder already holds with the ones you pass.

So if you want “SKU starts with ABC or SKU starts with XYZ, and the product is enabled”, you need two groups:

$skuAbc = $this->filterBuilder->setField('sku')->setValue('ABC%')->setConditionType('like')->create();
$skuXyz = $this->filterBuilder->setField('sku')->setValue('XYZ%')->setConditionType('like')->create();

$criteria = $this->searchCriteriaBuilder
    ->addFilters([$skuAbc, $skuXyz])   // group 1: OR
    ->addFilter('status', 1)           // group 2: AND
    ->create();

Calling addFilter() twice for the two SKU patterns would ask for products whose SKU starts with both ABC and XYZ at once, which returns nothing. This is the most common reason a “simple” repository query comes back empty.

What search criteria cannot express

Adobe’s REST search documentation is explicit about the ceiling: you cannot OR across filter groups, so a condition like (A AND B) OR (X AND Y) is not expressible. You also cannot nest groups. When you hit that wall you have three honest options:

  1. Run two queries and merge the results in PHP, which is fine for small result sets.
  2. Add a custom filter to the repository’s collection processor that knows how to apply the compound condition (covered below).
  3. Drop to a collection or a dedicated resource model query for that one read path.

Condition types you will actually use

The full list lives in Adobe’s REST reference on performing searches. The ones that come up most:

Condition Meaning Notes
eq / neq Equals / not equal eq is the default when no condition is set
gt, gteq, lt, lteq Numeric and date comparisons Pass dates in the database format your field stores
in / nin Value in / not in a list In REST the list is comma separated; in PHP pass an array
like / nlike SQL pattern match You supply the % wildcards yourself
from / to Range boundaries Adobe says they must be used together
null / notnull Field is null / not null The value is ignored
finset / nfinset Value inside a comma-separated set column Useful on multiselect attributes stored as text

Two practical notes. like with a leading wildcard (%term) cannot use a normal index, so it gets slow on large tables. And the REST docs state that you can only search top-level attributes, so nested data such as order item fields is not filterable through the order list endpoint.

Sorting

Sort orders are added with addSortOrder() or setSortOrders():

$sort = $this->sortOrderBuilder->setField('entity_id')->setDirection('ASC')->create();
$criteria = $this->searchCriteriaBuilder->addSortOrder($sort)->create();

Always sort on a unique, stable column when you page through data. If you sort only on something like created_at or price, rows that tie can shift between pages and you will process some twice and skip others. Adding entity_id as a secondary sort removes the ambiguity.

Paging without infinite loops

Adobe’s REST documentation notes that if you do not set a page size, the system returns all matches. On a catalog with hundreds of thousands of products, an unpaged getList() is a memory problem waiting for a busy day. Always set a page size in batch code.

The second trap is the stop condition. Collection-backed repositories inherit Magento’s collection behaviour of clamping the current page: ask for a page beyond the last one and you get the last page again rather than an empty result. A loop that runs “until getItems() is empty” never ends. Use the total count instead:

$pageSize = 200;
$page = 1;
do {
    $criteria = $this->searchCriteriaBuilder
        ->addFilter('status', 1)
        ->addSortOrder($this->sortOrderBuilder->setField('entity_id')->setDirection('ASC')->create())
        ->setPageSize($pageSize)
        ->setCurrentPage($page)
        ->create();

    $result = $this->productRepository->getList($criteria);
    foreach ($result->getItems() as $product) {
        // process
    }
    $lastPage = (int) ceil($result->getTotalCount() / $pageSize);
    $page++;
} while ($page <= $lastPage);

Note that the criteria are rebuilt inside the loop. That is deliberate, and it connects to the next section.

For search-engine backed lists there is a further ceiling: Adobe documents a default maximum of 10,000 results for Elasticsearch and OpenSearch, configured in the module’s di.xml. Deep pagination past that point needs a different approach, such as filtering on an ID range.

Builder state: when create() matters

SearchCriteriaBuilder keeps internal state (filters, sort orders, page size) until you call create(), which returns the criteria and resets the builder. Jisse Reitsma’s analysis at Yireo on SearchCriteriaBuilder versus its factory walks through the concern: if one class adds filters and never calls create(), leftover filters could appear in another class’s query. The article concludes that Magento’s core di.xml declares the builder as non-shared, so each class normally receives its own instance.

The practical habits that follow:

  • Always finish a build with create(), even if you abandon the query.
  • Inside one class that runs several queries, rebuild the criteria each time rather than reusing a half-built builder.
  • If you need a guaranteed clean builder in an unusual context, inject SearchCriteriaBuilderFactory and call create() on the factory to get a fresh builder.

How getList applies criteria: CollectionProcessor

Most core repositories do not translate criteria by hand. They inject a CollectionProcessorInterface and call process($searchCriteria, $collection). Adobe’s documentation describes four processors, wired together with virtual types in di.xml:

  • FilterProcessor applies filter groups, calling addFieldToFilter() unless a custom filter handles that field. It defaults to eq.
  • SortingProcessor applies sort orders, with optional field mapping and default orders when none are given.
  • PaginationProcessor applies page size and current page.
  • JoinProcessor adds joins through classes implementing CustomJoinInterface.

This is the extension point most teams miss. If a filter needs special SQL, such as filtering products by category or applying a compound condition, you write a class implementing CustomFilterInterface, return true once you have applied it, and register it in the customFilters argument of the relevant FilterProcessor virtual type. Adobe’s example maps category_id to addCategoriesFilter(). The same fieldMapping argument lets you expose a friendly criteria field name that maps to a different column. If virtual types are new to you, our guide to virtual types in di.xml explains how these argument overrides work.

Adobe also notes that EAV repositories need the EAV filter processor, otherwise custom filters are not added to the collection.

Writing your own repository’s getList

For a custom entity, the clean implementation is short:

public function getList(SearchCriteriaInterface $searchCriteria): WarrantySearchResultsInterface
{
    $collection = $this->collectionFactory->create();
    $this->collectionProcessor->process($searchCriteria, $collection);

    $results = $this->searchResultsFactory->create();
    $results->setSearchCriteria($searchCriteria);
    $results->setItems($collection->getItems());
    $results->setTotalCount($collection->getSize());
    return $results;
}

Hand-rolled implementations that loop over filter groups and call addFieldToFilter() once per filter are a classic source of bugs, because they turn every filter into AND and silently break OR semantics. A long-standing core GitHub issue (#4287) documented exactly this inconsistency between core repositories. Using the collection processor gives your repository the documented behaviour for free. If you also need to attach custom data to entities returned by getList, pair this with extension attributes on orders, products, and customers rather than adding columns to core tables.

When not to use a repository

Repositories are the right default for module code that other modules will call, for anything exposed through the API, and for writes. They are not always the right tool for heavy reads:

  • Bulk exports and reports. Repositories load full data models. For a nightly export of a few fields across a large catalog, a collection with an explicit addAttributeToSelect() list, or a direct resource-model query, uses a fraction of the memory.
  • Storefront rendering. On a Hyva or Luma page, product lists normally come from layered navigation and the search engine, not from getList() in a template. Calling a repository from a view model on every request skips the caching those layers provide.
  • Compound logic. If the condition genuinely needs (A AND B) OR (X AND Y), write that query once in a resource model with a clear method name instead of contorting criteria.

When you do bypass a repository, keep it inside one class with an obvious name so the decision is visible in review. Our guide to choosing between plugins, observers, and preferences applies the same principle to customization points: pick the narrowest tool that does the job and make the choice explicit.

The REST equivalent

The REST query string mirrors the PHP structure. Filters in the same filter_groups[n] index are ORed, and different indexes are ANDed:

GET /rest/V1/products?
 searchCriteria[filter_groups][0][filters][0][field]=sku&
 searchCriteria[filter_groups][0][filters][0][value]=ABC%25&
 searchCriteria[filter_groups][0][filters][0][condition_type]=like&
 searchCriteria[filter_groups][0][filters][1][field]=sku&
 searchCriteria[filter_groups][0][filters][1][value]=XYZ%25&
 searchCriteria[filter_groups][0][filters][1][condition_type]=like&
 searchCriteria[filter_groups][1][filters][0][field]=status&
 searchCriteria[filter_groups][1][filters][0][value]=1&
 searchCriteria[pageSize]=100&
 searchCriteria[currentPage]=1

That is the REST form of the SKU example earlier. Integration partners such as ERPs and PIMs hit these endpoints constantly, so getting filter groups right on the server side matters as much for integrations as it does for your own modules.

Getting help with Magento service contracts

Clean repository code is a large part of what makes a Magento or Adobe Commerce codebase upgradeable. Our Magento development services cover module architecture, service contracts, and integration work, and our Hyva development services pair that backend discipline with fast storefronts. Merchants weighing other platforms can compare how data access and APIs work on Shopify development, Shopware development, and BigCommerce development projects. You can learn more about Bemeir, browse our technology partners, or get in touch through the Bemeir homepage.

FAQ

How do I create an OR condition with SearchCriteriaBuilder?

Put the filters in the same filter group. Build each filter with FilterBuilder, then pass them together to addFilters([$a, $b]). Each call to addFilter() creates its own group, and separate groups are joined with AND, so calling it twice gives you AND, not OR.

Why does my Magento getList() return an empty result?

The most common cause is calling addFilter() once per alternative value, which ANDs conditions that can never all be true. Other causes are a like value without % wildcards, a field name that is not a top-level attribute, and a custom repository that ignores filter groups.

Why does my getList() pagination loop never stop?

Collection-backed repositories return the last page again when you request a page beyond the end, so a loop that waits for an empty result never exits. Calculate the last page from getTotalCount() divided by your page size, and sort on a unique column such as entity_id.

Can search criteria express (A AND B) OR (X AND Y)?

No. Adobe documents that you cannot OR across filter groups. Run separate queries and merge them, add a custom filter to the repository’s collection processor, or write that one query in a resource model.

Should I inject SearchCriteriaBuilder or SearchCriteriaBuilderFactory?

Injecting SearchCriteriaBuilder is fine in normal code because core declares it as non-shared, and create() resets its state. Use the factory when you need several independent builders at once or want a guaranteed clean instance in an unusual context.

Let us help you get started on a project with Magento 2 Repositories and SearchCriteriaBuilder: Filters, Filter Groups, Sorting, and Paging With getList and leverage our partnership to your fullest advantage. Fill out the contact form below to get started.

more articles about ecommerce

Read on the latest with Shopify, Magento, eCommerce topics and more.