Builder Pattern
in Magento 2
The builder pattern creates complex objects step by step. In Magento 2 it matters most for SearchCriteria, filters, sort orders and stable configuration objects for services.
Table of Contents
1. What is the builder pattern?
The builder pattern in Magento 2 solves a very practical problem: some objects are too complex to build cleanly through a long constructor or a large array. An object might need optional filters, several sort orders, pagination, store context, flags, validation and default values. When all of these parameters go straight into the constructor, the code quickly becomes hard to read. The builder pattern therefore separates the construction of an object from its final use.
The idea is simple: a builder collects the required data step by step and finally produces the finished object with create() or build(). This keeps the calling code readable. You not only see which values are passed, but also what those values mean. That is exactly why the builder pattern in Magento 2 is so useful for SearchCriteriaBuilder, FilterBuilder and SortOrderBuilder.
A classic example is a product search. You need a category, an active status, a visibility, a sort order by price and maybe a page size limit. As an array this would be error prone. As a constructor with ten parameters it would be confusing. With a builder this becomes a readable sequence of steps: add a filter, set the sort order, set the page size, create the finished SearchCriteria object.
<?php
declare(strict_types=1);
/**
* Simple builder example for a report request.
*/
final class ReportRequestBuilder
{
private ?int $storeId = null;
private array $filters = [];
private int $pageSize = 20;
/**
* Sets the store scope for the report.
*/
public function withStoreId(int $storeId): self
{
$this->storeId = $storeId;
return $this;
}
/**
* Adds a named filter to the report request.
*/
public function withFilter(string $field, mixed $value): self
{
$this->filters[$field] = $value;
return $this;
}
/**
* Sets the maximum number of rows.
*/
public function withPageSize(int $pageSize): self
{
$this->pageSize = $pageSize;
return $this;
}
/**
* Creates the immutable request object.
*/
public function build(): ReportRequest
{
return new ReportRequest(
storeId: $this->storeId,
filters: $this->filters,
pageSize: $this->pageSize
);
}
}
It is important to note: the builder itself does not have to be the business object. It is a tool for construction. The result can be a DTO, a value object, a SearchCriteria object or another structured request. In clean Magento code, the final object stays as clear and stable as possible, while the builder handles the flexible creation.
2. Builder pattern in Magento 2
The builder pattern in Magento 2 shows up in everyday work more often than it first appears. It is especially prominent in the API layer around repositories. Magento repositories expect a SearchCriteriaInterface for list queries. This object can contain filter groups, sort orders, current page and page size. Because these combinations are dynamic, a direct constructor would be impractical. That is why Magento provides SearchCriteriaBuilder.
In addition, there are specialized builders such as FilterBuilder, FilterGroupBuilder and SortOrderBuilder. These classes help create individual parts of a search request. The benefit is not just readability. The builder pattern in Magento 2 also supports service contracts: your service works against interfaces while the builder correctly assembles the concrete object structure.
A common mistake is passing filters as raw arrays through multiple services. This feels fast at first but leads to fuzzy contracts. Later, nobody reliably knows which keys are allowed, which condition types are expected, or whether pagination was set. Builders and interfaces make this structure more explicit. That matters especially when a module is later used through REST, GraphQL, cron, Adminhtml or CLI.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Service;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\Api\FilterBuilder;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SortOrder;
use Magento\Framework\Api\SortOrderBuilder;
/**
* Loads visible products for a category by using Magento search criteria builders.
*/
final class CategoryProductProvider
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
private readonly FilterBuilder $filterBuilder,
private readonly SortOrderBuilder $sortOrderBuilder
) {}
/**
* Returns products for a category with deterministic sorting and pagination.
*
* @return ProductInterface[]
*/
public function getProducts(int $categoryId, int $pageSize = 12): array
{
$categoryFilter = $this->filterBuilder
->setField('category_id')
->setValue($categoryId)
->setConditionType('eq')
->create();
$statusFilter = $this->filterBuilder
->setField('status')
->setValue(1)
->setConditionType('eq')
->create();
$sortOrder = $this->sortOrderBuilder
->setField('price')
->setDirection(SortOrder::SORT_ASC)
->create();
$searchCriteria = $this->searchCriteriaBuilder
->addFilters([$categoryFilter, $statusFilter])
->addSortOrder($sortOrder)
->setPageSize($pageSize)
->setCurrentPage(1)
->create();
return $this->productRepository->getList($searchCriteria)->getItems();
}
}
This code shows the typical Magento approach: repository instead of a direct collection, SearchCriteria instead of free-form SQL logic, builder instead of unclear arrays. The builder pattern in Magento 2 makes the query describable and transportable. At the same time the service stays testable, because repository and builder arrive through constructor injection.
3. Using SearchCriteriaBuilder correctly
The SearchCriteriaBuilder is the most important practical example of the builder pattern in Magento 2. It creates a SearchCriteriaInterface object that is accepted by repository methods such as getList(). This lets you define filters, sort orders and pagination without directly manipulating a concrete collection. That is the cleaner architecture, because your business code does not need to know whether the data later comes from MySQL, Elasticsearch, an index or another source.
When using it, though, you need to understand one quirk: many Magento builders are stateful. If the same builder is used multiple times within one method, you must be careful that create() processes the current state and, depending on the implementation, either resets it or does not behave exactly as you expect. That is why it makes sense to use builders only locally and in a manageable scope. In longer flows, a dedicated builder or a factory for clearly defined search requests is often cleaner.
One more detail: multiple filters within one filter group are logically interpreted as OR, while multiple filter groups are combined as AND. The convenience method addFilters() is handy for simple cases. As soon as you need complex AND/OR structures, you should work explicitly with FilterGroupBuilder. Here too, the builder pattern in Magento 2 shows its value: complexity becomes visible and stays controllable.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Service;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Api\FilterBuilder;
use Magento\Framework\Api\FilterGroupBuilder;
use Magento\Framework\Api\SearchCriteriaBuilder;
/**
* Demonstrates explicit filter groups for AND and OR search criteria.
*/
final class ProductSearchService
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly FilterBuilder $filterBuilder,
private readonly FilterGroupBuilder $filterGroupBuilder,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder
) {}
/**
* Returns products that are active and match one of the given SKUs.
*/
public function findActiveProductsBySkus(array $skus): array
{
$statusFilter = $this->filterBuilder
->setField('status')
->setValue(1)
->setConditionType('eq')
->create();
$statusGroup = $this->filterGroupBuilder
->setFilters([$statusFilter])
->create();
$skuFilters = [];
foreach ($skus as $sku) {
$skuFilters[] = $this->filterBuilder
->setField('sku')
->setValue((string) $sku)
->setConditionType('eq')
->create();
}
$skuGroup = $this->filterGroupBuilder
->setFilters($skuFilters)
->create();
$searchCriteria = $this->searchCriteriaBuilder
->setFilterGroups([$statusGroup, $skuGroup])
->setPageSize(50)
->create();
return $this->productRepository->getList($searchCriteria)->getItems();
}
}
In this example, the structure means: status must be active, and the SKU must be one of the given SKUs. For real projects this clarity matters a lot. In Magento 2, bugs often come not from missing features but from unclear data structures. The builder pattern in Magento 2 helps make these structures visible in the code.
4. A custom builder for complex requests
Not every builder has to come from the Magento framework. When a module creates complex requests or configuration objects, a custom builder can make sense. This applies for example to exports, price calculations, feed generation, synchronization with ERP systems or headless endpoints. What matters is that the builder encapsulates real complexity and is not just a factory with a different name.
A custom builder is especially useful when there are many optional values, default values need to be set, or validation rules should apply before the final object is created. The final object should then be as immutable as possible. In PHP 8.4 you can use constructor property promotion, readonly properties and typed constants for this. This keeps the result stable and easy to test.
The following example shows a slim request structure for a product export. The builder collects store, customer group, fields, filters and batch size. The final request can then be passed to an export service. This keeps the service free of construction logic, and the controller, cron job or CLI command does not need to assemble long arrays.
<?php
declare(strict_types=1);
namespace Mironsoft\ProductExport\Api\Data;
/**
* Describes an immutable product export request.
*/
interface ProductExportRequestInterface
{
/**
* Returns the store ID for the export scope.
*/
public function getStoreId(): int;
/**
* Returns the customer group ID for price context.
*/
public function getCustomerGroupId(): int;
/**
* Returns the selected export fields.
*
* @return string[]
*/
public function getFields(): array;
/**
* Returns request filters indexed by field name.
*
* @return array<string, mixed>
*/
public function getFilters(): array;
/**
* Returns the batch size for streaming exports.
*/
public function getBatchSize(): int;
}
<?php
declare(strict_types=1);
namespace Mironsoft\ProductExport\Model\Data;
use Mironsoft\ProductExport\Api\Data\ProductExportRequestInterface;
/**
* Immutable data object for product export configuration.
*/
final readonly class ProductExportRequest implements ProductExportRequestInterface
{
/**
* Defines the minimum allowed export batch size.
*/
public const int MIN_BATCH_SIZE = 10;
/**
* Defines the maximum allowed export batch size.
*/
public const int MAX_BATCH_SIZE = 1000;
/**
* @param string[] $fields
* @param array<string, mixed> $filters
*/
public function __construct(
private int $storeId,
private int $customerGroupId,
private array $fields,
private array $filters,
private int $batchSize
) {}
public function getStoreId(): int
{
return $this->storeId;
}
public function getCustomerGroupId(): int
{
return $this->customerGroupId;
}
public function getFields(): array
{
return $this->fields;
}
public function getFilters(): array
{
return $this->filters;
}
public function getBatchSize(): int
{
return $this->batchSize;
}
}
<?php
declare(strict_types=1);
namespace Mironsoft\ProductExport\Model;
use InvalidArgumentException;
use Mironsoft\ProductExport\Model\Data\ProductExportRequest;
/**
* Builds product export requests with validation and safe defaults.
*/
final class ProductExportRequestBuilder
{
private int $storeId = 1;
private int $customerGroupId = 0;
private array $fields = ['sku', 'name', 'price'];
private array $filters = [];
private int $batchSize = 100;
/**
* Sets the store scope.
*/
public function withStoreId(int $storeId): self
{
if ($storeId <= 0) {
throw new InvalidArgumentException('Store ID must be greater than zero.');
}
$this->storeId = $storeId;
return $this;
}
/**
* Sets the customer group context.
*/
public function withCustomerGroupId(int $customerGroupId): self
{
if ($customerGroupId < 0) {
throw new InvalidArgumentException('Customer group ID must not be negative.');
}
$this->customerGroupId = $customerGroupId;
return $this;
}
/**
* Replaces the export field selection.
*
* @param string[] $fields
*/
public function withFields(array $fields): self
{
if ($fields === []) {
throw new InvalidArgumentException('At least one export field is required.');
}
$this->fields = array_values(array_unique($fields));
return $this;
}
/**
* Adds a filter to the export request.
*/
public function withFilter(string $field, mixed $value): self
{
$this->filters[$field] = $value;
return $this;
}
/**
* Sets the batch size for export processing.
*/
public function withBatchSize(int $batchSize): self
{
if ($batchSize < ProductExportRequest::MIN_BATCH_SIZE || $batchSize > ProductExportRequest::MAX_BATCH_SIZE) {
throw new InvalidArgumentException('Batch size is outside the allowed range.');
}
$this->batchSize = $batchSize;
return $this;
}
/**
* Creates the final product export request.
*/
public function build(): ProductExportRequest
{
return new ProductExportRequest(
storeId: $this->storeId,
customerGroupId: $this->customerGroupId,
fields: $this->fields,
filters: $this->filters,
batchSize: $this->batchSize
);
}
}
This is deliberately not a Magento model and not a ResourceModel structure. It is a service-adjacent object. That is exactly where the builder pattern in Magento 2 fits best: places where a service needs a precise request, but creating that request is variable. The controller can, for example, read request parameters and pass them to the builder. A cron job can use the same builder with fixed values. An integration test can use the builder to build realistic requests.
<?php
declare(strict_types=1);
namespace Mironsoft\ProductExport\Service;
use Mironsoft\ProductExport\Model\ProductExportRequestBuilder;
/**
* Demonstrates client code that stays readable by using a request builder.
*/
final class ExportScheduler
{
public function __construct(
private readonly ProductExportRequestBuilder $requestBuilder,
private readonly ProductExportService $productExportService
) {}
/**
* Schedules a product export for the given store.
*/
public function scheduleDailyExport(int $storeId): void
{
$request = $this->requestBuilder
->withStoreId($storeId)
->withCustomerGroupId(0)
->withFields(['sku', 'name', 'price', 'url_key'])
->withFilter('status', 1)
->withFilter('visibility', [2, 3, 4])
->withBatchSize(250)
->build();
$this->productExportService->schedule($request);
}
}
With custom builders, though, you need to watch reuse. If a builder is stateful and injected as a shared service, leftover state can become a problem. In Magento it is often better to use a factory for the builder, or to design the builder so that build() resets internally. For simple cases, a locally used builder is enough. For complex, repeatedly used processes, the builder's lifetime should be decided deliberately.
5. Comparison: builder vs. factory vs. constructor
The builder pattern in Magento 2 is not automatically the right choice for every object. It often competes with factories, plain constructors and data interfaces. The best solution depends on how complex the creation is and how many optional variants exist.
| Approach | Well suited for | Problem when misused |
|---|---|---|
| Constructor | Small, clear objects with few required values | Long parameter lists, unreadable calls, many null values |
| Factory | Objects with Magento DI, generated models, new instances | Factory gets overloaded with construction and validation logic |
| Builder | Complex, optional, step-by-step configured objects | State bugs when the same builder is reused uncontrolled |
| Array | Very small internal options without an API character | No type safety, no IDE support, fragile magic keys |
A factory answers the question: "How do I get a new instance?" A builder answers the question: "How do I configure a complex instance cleanly?" That is an important distinction. In Magento 2 you should still use factories for models, collections and non-injectable objects. The builder pattern in Magento 2 fits better when the creation itself consists of several semantic steps.
Another point of comparison is testability. A long constructor is typed but hard to read. An array is flexible but unsafe. A builder can combine both: clear methods and flexible ordering. Still, a builder should not turn into a hidden service. Business decisions belong in services, not in builders. The builder assembles data, validates simple structural rules and creates the final object.
Mironsoft
Magento 2 architecture, modules and Hyva frontends
Want to structure your Magento code with clear patterns?
We build Magento 2 modules with service contracts, repositories, view models, clean dependency injection and traceable design patterns instead of short-term workarounds.
Architecture
Use builder, factory, repository and plugins where they fit
Magento 2.4.8
PHP 8.4, service contracts and a modern module structure
Hyva
Fast storefronts without Luma, Knockout or jQuery
7. Summary
The builder pattern in Magento 2 is a practical tool for complex object creation. It lets you build filters, sort orders, export requests or other configuration objects step by step. Especially with SearchCriteriaBuilder, FilterBuilder and SortOrderBuilder, the pattern is part of normal Magento work.
Used cleanly, the builder pattern in Magento 2 improves readability, reduces array magic and keeps services free of construction details. However, it does not replace factory, repository or business service. The builder is responsible for construction, the repository for persistence and the service for business decisions. Whoever respects these boundaries gets maintainable Magento code that stays stable even in larger projects.
Builder Pattern in Magento 2: the essentials at a glance
Main purpose
Build complex objects step by step, readably and in a validatable way.
Magento examples
SearchCriteriaBuilder, FilterBuilder, FilterGroupBuilder, SortOrderBuilder.
Good use cases
Search requests, export requests, optional configurations, structured API parameters.
Caution
Builders are often stateful. Decide lifetime, reset behavior and reuse deliberately.
8. FAQ: Builder Pattern in Magento 2
1 What is the builder pattern in Magento 2?
SearchCriteriaBuilder, FilterBuilder and SortOrderBuilder.2 When should you use the builder pattern?
3 Builder vs. factory: what is the difference?
4 Why is SearchCriteriaBuilder important?
SearchCriteriaInterface objects for repository list queries. This keeps code independent of direct collections and SQL details.5 Are builders stateful?
create() or build(). That is why you should handle reuse, reset behavior and service lifetime deliberately.6 How do AND and OR work in filter groups?
FilterGroupBuilder explicitly.7 Does business logic belong in the builder?
8 Can you write custom builders?
9 Are arrays a good alternative?
10 Does the pattern fit PHP 8.4?
readonly properties and typed constants make custom builders and final request objects especially clear in PHP 8.4.