Magento 2 · Builder Pattern

Builder Pattern
in Magento 2

Das Builder Pattern erzeugt komplexe Objekte Schritt für Schritt. In Magento 2 ist es besonders wichtig bei SearchCriteria, Filtern, Sortierungen und stabilen Konfigurationsobjekten für Services.

12 Min. Lesezeit PHP 8.4 Magento 2.4.8

1. Was ist das Builder Pattern?

Das Builder Pattern Magento 2 löst ein sehr praktisches Problem: Manche Objekte sind zu komplex, um sie sauber über einen langen Constructor oder ein großes Array zu erstellen. Ein Objekt braucht vielleicht optionale Filter, mehrere Sortierungen, Pagination, Store-Kontext, Flags, Validierung und Standardwerte. Wenn all diese Parameter direkt an den Constructor gehen, entsteht schnell schwer lesbarer Code. Das Builder Pattern trennt deshalb den Aufbau eines Objekts von seiner finalen Nutzung.

Die Idee ist einfach: Ein Builder sammelt die benötigten Daten Schritt für Schritt und erzeugt am Ende mit create() oder build() das fertige Objekt. Dadurch bleibt der aufrufende Code lesbar. Man sieht nicht nur, welche Werte übergeben werden, sondern auch, was diese Werte bedeuten. Genau deshalb ist das Builder Pattern Magento 2 so nützlich bei SearchCriteriaBuilder, FilterBuilder und SortOrderBuilder.

Ein klassisches Beispiel ist eine Suche nach Produkten. Du brauchst eine Kategorie, einen Aktiv-Status, eine Sichtbarkeit, eine Sortierung nach Preis und vielleicht eine Seitenbegrenzung. Als Array wäre das fehleranfällig. Als Constructor mit zehn Parametern wäre es unübersichtlich. Mit einem Builder wird daraus eine lesbare Sequenz von Schritten: Filter hinzufügen, Sortierung setzen, Seitengröße setzen, fertiges SearchCriteria-Objekt erzeugen.


<?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
        );
    }
}

Wichtig ist dabei: Der Builder selbst muss nicht das Business-Objekt sein. Er ist ein Werkzeug für die Konstruktion. Das Ergebnis kann ein DTO, ein Value Object, ein SearchCriteria-Objekt oder eine andere strukturierte Anfrage sein. In sauberem Magento-Code bleibt das finale Objekt möglichst klar und stabil, während der Builder die flexible Erzeugung übernimmt.

2. Builder Pattern in Magento 2

Das Builder Pattern Magento 2 begegnet dir im Alltag häufiger, als es auf den ersten Blick wirkt. Besonders prominent ist es in der API-Schicht rund um Repositories. Magento-Repositories erwarten für Listenabfragen ein SearchCriteriaInterface. Dieses Objekt kann Filtergruppen, Sortierungen, aktuelle Seite und Seitengröße enthalten. Weil diese Kombinationen dynamisch sind, wäre ein direkter Constructor unpraktisch. Magento stellt deshalb den SearchCriteriaBuilder bereit.

Zusätzlich gibt es spezialisierte Builder wie FilterBuilder, FilterGroupBuilder und SortOrderBuilder. Diese Klassen helfen, einzelne Teile einer Suchanfrage zu erzeugen. Der Vorteil liegt nicht nur in der Lesbarkeit. Das Builder Pattern Magento 2 unterstützt auch Service Contracts: Dein Service arbeitet gegen Interfaces, während der Builder die konkrete Objektstruktur korrekt zusammensetzt.

Ein häufiger Fehler besteht darin, Filter als rohe Arrays durch mehrere Services zu reichen. Das wirkt zunächst schnell, führt aber zu unscharfen Verträgen. Niemand weiß später zuverlässig, welche Keys erlaubt sind, welche Condition Types erwartet werden und ob Pagination gesetzt wurde. Mit Buildern und Interfaces wird diese Struktur expliziter. Das ist besonders wichtig, wenn ein Modul später über REST, GraphQL, Cron, Adminhtml oder CLI genutzt werden soll.


<?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();
    }
}

Dieser Code zeigt den typischen Magento-Weg: Repository statt direkter Collection, SearchCriteria statt freier SQL-Logik, Builder statt unklarer Arrays. Das Builder Pattern Magento 2 macht die Query beschreibbar und transportierbar. Gleichzeitig bleibt der Service testbar, weil Repository und Builder per Constructor Injection kommen.

3. SearchCriteriaBuilder richtig verwenden

Der SearchCriteriaBuilder ist das wichtigste Praxisbeispiel für das Builder Pattern Magento 2. Er erzeugt ein SearchCriteriaInterface, das von Repository-Methoden wie getList() akzeptiert wird. Damit kannst du Filter, Sortierungen und Pagination definieren, ohne eine konkrete Collection direkt zu manipulieren. Das ist die sauberere Architektur, weil dein Geschäftscode nicht wissen muss, ob die Daten später aus MySQL, Elasticsearch, einem Index oder einer anderen Quelle kommen.

Bei der Nutzung muss man aber eine Eigenheit verstehen: Viele Magento-Builder sind zustandsbehaftet. Wenn derselbe Builder innerhalb einer Methode mehrfach verwendet wird, musst du darauf achten, dass create() den aktuellen Zustand verarbeitet und je nach Implementierung zurücksetzt oder nicht vollständig so funktioniert, wie du es erwartest. Deshalb ist es sinnvoll, Builder nur lokal und überschaubar zu verwenden. In längeren Abläufen ist ein eigener Builder oder eine Factory für klar definierte Suchanfragen oft sauberer.

Ein weiteres Detail: Mehrere Filter in einer Filtergruppe werden logisch als OR verstanden, während mehrere Filtergruppen als AND kombiniert werden. Die Convenience-Methode addFilters() ist für einfache Fälle praktisch. Sobald du komplexe AND/OR-Strukturen brauchst, solltest du explizit mit FilterGroupBuilder arbeiten. Auch hier zeigt das Builder Pattern Magento 2 seinen Wert: Komplexität wird sichtbar und bleibt kontrollierbar.


<?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 diesem Beispiel bedeutet die Struktur: Status muss aktiv sein, und die SKU muss eine der angegebenen SKUs sein. Für reale Projekte ist diese Klarheit entscheidend. Gerade in Magento 2 entstehen Fehler oft nicht durch fehlende Funktionen, sondern durch unklare Datenstrukturen. Das Builder Pattern Magento 2 hilft, diese Strukturen im Code sichtbar zu machen.

4. Eigener Builder für komplexe Requests

Nicht jeder Builder muss aus dem Magento Framework kommen. Wenn ein Modul komplexe Anfragen oder Konfigurationsobjekte erzeugt, kann ein eigener Builder sinnvoll sein. Das gilt zum Beispiel für Exporte, Preisberechnungen, Feed-Generierung, Synchronisationen mit ERP-Systemen oder Headless-Endpunkte. Entscheidend ist, dass der Builder eine echte Komplexität kapselt und nicht nur eine Factory mit anderem Namen ist.

Ein eigener Builder ist besonders nützlich, wenn viele optionale Werte existieren, Standardwerte gesetzt werden müssen oder Validierungsregeln vor dem Erzeugen des finalen Objekts greifen sollen. Das finale Objekt sollte dann möglichst unveränderlich sein. In PHP 8.4 kannst du dafür Constructor Property Promotion, readonly Properties und typisierte Konstanten nutzen. So wird das Ergebnis stabil und gut testbar.

Das folgende Beispiel zeigt eine schlanke Request-Struktur für einen Produkt-Export. Der Builder sammelt Store, Kundengruppe, Felder, Filter und Batch-Größe. Der finale Request kann anschließend an einen Export-Service übergeben werden. So bleibt der Service frei von Aufbau-Logik, und der Controller, Cron oder CLI-Command muss keine langen Arrays zusammenbauen.


<?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
        );
    }
}

Das ist bewusst kein Magento-Model und keine ResourceModel-Struktur. Es ist ein Service-nahes Objekt. Genau dort passt das Builder Pattern Magento 2 am besten: an Stellen, an denen ein Service eine präzise Anfrage braucht, aber die Erzeugung dieser Anfrage variabel ist. Der Controller kann zum Beispiel Request-Parameter auslesen und an den Builder übergeben. Ein Cronjob kann denselben Builder mit festen Werten nutzen. Ein Integrationstest kann den Builder verwenden, um realistische Requests aufzubauen.


<?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);
    }
}

Bei eigenen Buildern musst du allerdings auf Wiederverwendung achten. Wenn ein Builder zustandsbehaftet ist und als Shared Service injiziert wird, kann Restzustand problematisch werden. In Magento ist es oft besser, eine Factory für den Builder zu verwenden oder den Builder so zu gestalten, dass build() intern zurücksetzt. Für einfache Fälle reicht ein lokal genutzter Builder. Für komplexe, mehrfach genutzte Prozesse sollte die Lebensdauer des Builders klar entschieden werden.

5. Vergleich: Builder vs. Factory vs. Constructor

Das Builder Pattern Magento 2 ist nicht automatisch die richtige Wahl für jedes Objekt. Es konkurriert oft mit Factories, normalen Constructors und Data Interfaces. Die beste Lösung hängt davon ab, wie komplex die Erzeugung ist und wie viele optionale Varianten existieren.

Ansatz Gut geeignet für Problem bei falscher Nutzung
Constructor Kleine, klare Objekte mit wenigen Pflichtwerten Lange Parameterlisten, unlesbare Aufrufe, viele Null-Werte
Factory Objekte mit Magento-DI, generierte Models, neue Instanzen Factory wird mit Aufbau- und Validierungslogik überladen
Builder Komplexe, optionale, schrittweise konfigurierte Objekte Zustandsfehler, wenn derselbe Builder unkontrolliert wiederverwendet wird
Array Sehr kleine interne Optionen ohne API-Charakter Keine Typsicherheit, keine IDE-Hilfe, fragile Magic Keys

Eine Factory beantwortet die Frage: „Wie bekomme ich eine neue Instanz?“ Ein Builder beantwortet die Frage: „Wie konfiguriere ich eine komplexe Instanz sauber?“ Das ist ein wichtiger Unterschied. In Magento 2 solltest du Factories weiterhin für Models, Collections und nicht injizierbare Objekte nutzen. Das Builder Pattern Magento 2 passt besser, wenn die Erzeugung selbst aus mehreren semantischen Schritten besteht.

Ein weiterer Vergleichspunkt ist Testbarkeit. Ein langer Constructor ist zwar typisiert, aber schwer lesbar. Ein Array ist flexibel, aber unsicher. Ein Builder kann beides verbinden: klare Methoden und flexible Reihenfolge. Trotzdem sollte ein Builder nicht zu einem versteckten Service werden. Business-Entscheidungen gehören in Services, nicht in Builder. Der Builder setzt Daten zusammen, validiert einfache Strukturregeln und erzeugt das finale Objekt.

Mironsoft

Magento 2 Architektur, Module und Hyvä Frontends

Magento-Code mit klaren Patterns strukturieren?

Wir entwickeln Magento 2 Module mit Service Contracts, Repositories, ViewModels, sauberer Dependency Injection und nachvollziehbaren Design Patterns statt kurzfristiger Workarounds.

Architektur

Builder, Factory, Repository und Plugins passend einsetzen

Magento 2.4.8

PHP 8.4, Service Contracts und moderne Modulstruktur

Hyvä

Schnelle Storefronts ohne Luma, Knockout oder jQuery

7. Zusammenfassung

Das Builder Pattern Magento 2 ist ein praktisches Werkzeug für komplexe Objekterzeugung. Es sorgt dafür, dass Filter, Sortierungen, Export-Requests oder andere Konfigurationsobjekte Schritt für Schritt aufgebaut werden können. Besonders bei SearchCriteriaBuilder, FilterBuilder und SortOrderBuilder ist das Pattern Teil des normalen Magento-Alltags.

Sauber eingesetzt verbessert das Builder Pattern Magento 2 die Lesbarkeit, reduziert Array-Magic und hält Services frei von Aufbau-Details. Es ersetzt aber nicht Factory, Repository oder Business-Service. Der Builder ist für Konstruktion zuständig, das Repository für Persistenz und der Service für fachliche Entscheidungen. Wer diese Grenzen einhält, bekommt wartbaren Magento-Code, der auch in größeren Projekten stabil bleibt.

Builder Pattern Magento 2 — Das Wichtigste auf einen Blick

Hauptzweck

Komplexe Objekte schrittweise, lesbar und validierbar erzeugen.

Magento-Beispiele

SearchCriteriaBuilder, FilterBuilder, FilterGroupBuilder, SortOrderBuilder.

Guter Einsatz

Suchanfragen, Export-Requests, optionale Konfigurationen, strukturierte API-Parameter.

Vorsicht

Builder sind oft zustandsbehaftet. Lebensdauer, Reset-Verhalten und Wiederverwendung bewusst festlegen.

8. FAQ: Builder Pattern in Magento 2

1 Was ist das Builder Pattern in Magento 2?
Ein Builder erzeugt komplexe Objekte Schritt für Schritt. In Magento 2 sieht man das besonders bei SearchCriteriaBuilder, FilterBuilder und SortOrderBuilder.
2 Wann sollte man das Builder Pattern verwenden?
Wenn ein Objekt viele optionale Werte, Filter, Sortierungen oder Standardwerte hat. Für kleine Objekte mit wenigen Pflichtwerten reicht meistens ein Constructor.
3 Builder vs. Factory: Was ist der Unterschied?
Eine Factory erzeugt eine Instanz. Ein Builder konfiguriert eine komplexe Instanz über mehrere Schritte. In Magento 2 sind beide Patterns sinnvoll, aber für unterschiedliche Aufgaben.
4 Warum ist SearchCriteriaBuilder wichtig?
Er erzeugt SearchCriteriaInterface-Objekte für Repository-Listenabfragen. Dadurch bleibt Code unabhängig von direkten Collections und SQL-Details.
5 Sind Builder zustandsbehaftet?
Ja, viele Builder speichern Werte bis create() oder build(). Deshalb sollte man Wiederverwendung, Reset-Verhalten und Service-Lebensdauer bewusst behandeln.
6 Wie funktionieren AND und OR in Filtergruppen?
Mehrere Filter innerhalb einer Filtergruppe wirken typischerweise als OR. Mehrere Filtergruppen werden als AND kombiniert. Für komplexe Suchlogik sollte man FilterGroupBuilder explizit nutzen.
7 Gehört Business-Logik in den Builder?
Nein. Builder enthalten Aufbau-Logik, Standardwerte und einfache Strukturvalidierung. Fachliche Entscheidungen gehören in Services, Validatoren oder Domain-Klassen.
8 Kann man eigene Builder schreiben?
Ja. Eigene Builder passen gut für Export-Requests, Integrationen, Preisberechnungen oder API-nahe Konfigurationen. Das Ergebnis sollte typisiert und möglichst unveränderlich sein.
9 Sind Arrays eine gute Alternative?
Für kleine interne Optionen manchmal. Für wiederverwendbare oder API-nahe Strukturen sind Arrays wegen Magic Keys, fehlender Typsicherheit und schlechter IDE-Unterstützung schwächer als Builder.
10 Passt das Pattern zu PHP 8.4?
Ja. Constructor Property Promotion, readonly Properties und typisierte Konstanten machen eigene Builder und finale Request-Objekte in PHP 8.4 besonders klar.