mironsoft.de › Blog › Design Patterns
Magento 2 · Design Patterns
Iterator & Collections
Pattern in Magento 2

Das Iterator Pattern steckt tief in Magento 2 Collections. AbstractCollection implementiert IteratorAggregate und lädt Daten lazy. Vom PHP Iterator Interface über EAV-Filter bis zu SearchCriteria — alles was Collections in Magento 2.4.8 wirklich können.

15 Min. Lesezeit PHP 8.4 Magento 2.4.8

Das PHP Iterator Interface — Grundlagen

Das Iterator Pattern ist eines der klassischen Design Patterns aus dem Gang-of-Four-Buch und beschreibt eine standardisierte Schnittstelle, um über Objekte in einer Sammlung zu iterieren — unabhängig davon, wie diese Sammlung intern organisiert ist. Der Aufrufer muss nicht wissen, ob die Daten in einem Array, einer verketteten Liste, einem Datenbankresultat oder einer anderen Datenstruktur gespeichert sind. Er ruft einfach next(), current() und valid() auf und erhält die Elemente nacheinander.

PHP implementiert das Iterator Pattern über zwei Interfaces in der Standard Library: Iterator und IteratorAggregate. Das Iterator-Interface definiert fünf Methoden: current(), key(), next(), rewind() und valid(). Das IteratorAggregate-Interface ist einfacher und definiert nur getIterator(), das ein Traversable-Objekt zurückgibt. Beide Interfaces ermöglichen die Nutzung der foreach-Sprachkonstruktion.

In PHP 8.4 gibt es wichtige Ergänzungen rund um die Typsicherheit von Iteratoren. Generic-artige Annotationen über PHPDoc (@extends \IteratorAggregate<int, PostInterface>) werden von PHPStan und Psalm verstanden und ermöglichen typsichere foreach-Schleifen. Wenn eine Collection über PostInterface-Objekte iteriert, weiß PHPStan, dass $post in der foreach-Schleife vom Typ PostInterface ist — und kann entsprechende Typchecks durchführen. Das ist ein erheblicher Fortschritt gegenüber dem ungetypten mixed der älteren Magento-Architektur.

Das Traversable-Interface ist das übergeordnete Interface in PHP, das sowohl Iterator als auch IteratorAggregate vereint. Alle nativen PHP-Funktionen, die mit Iterables arbeiten — wie iterator_to_array(), iterator_count() und viele SPL-Funktionen — akzeptieren Traversable-Objekte. Magento 2 Collections sind Traversable und können mit all diesen Funktionen genutzt werden.

AbstractCollection in Magento 2: Lazy Loading

Magento 2 Collections erweitern Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection. Diese Klasse ist das Herzstück des Collection-Systems und implementiert IteratorAggregate. Das bedeutet: Jede Magento 2 Collection ist ein Traversable-Objekt und kann mit foreach durchlaufen werden. Die getIterator()-Methode löst dabei das Laden der Daten aus — das ist Lazy Loading in seiner reinsten Form.

Lazy Loading bedeutet: Die Collection konfiguriert zunächst nur den SQL-Query (Filter, Sortierung, Pagination). Die eigentliche Datenbankabfrage wird erst ausgeführt, wenn tatsächlich auf die Daten zugegriffen wird. Solange man nur Filter hinzufügt und nichts liest, wird kein SQL ausgeführt. Das ist effizient, weil überflüssige Datenbankabfragen vermieden werden. Der Zustand isLoaded wird intern auf false initialisiert und nach dem ersten Laden auf true gesetzt.

<?php
declare(strict_types=1);

namespace Mironsoft\Blog\ViewModel;

use Magento\Framework\View\Element\Block\ArgumentInterface;
use Mironsoft\Blog\Model\ResourceModel\Post\CollectionFactory;

/**
 * Blog post list ViewModel — uses Collection with Lazy Loading.
 */
class PostList implements ArgumentInterface
{
    public function __construct(
        private readonly CollectionFactory $collectionFactory
    ) {}

    /**
     * Get active blog posts for the current page.
     *
     * @return \Mironsoft\Blog\Model\Post[]
     */
    public function getActivePosts(int $pageSize = 10, int $page = 1): array
    {
        // Collection is created — NO SQL yet
        $collection = $this->collectionFactory->create();

        // Filters and pagination are configured — still NO SQL
        $collection
            ->addFieldToFilter('is_active', ['eq' => 1])
            ->addFieldToFilter('publish_date', ['lteq' => date('Y-m-d H:i:s')])
            ->setOrder('publish_date', 'DESC')
            ->setPageSize($pageSize)
            ->setCurPage($page);

        // getItems() triggers SQL execution — Lazy Loading kicks in here
        return $collection->getItems();
    }

    /**
     * Get total count of active posts — SELECT COUNT(*) only.
     */
    public function getTotalPostCount(): int
    {
        $collection = $this->collectionFactory->create();
        $collection->addFieldToFilter('is_active', ['eq' => 1]);

        // getSize() executes SELECT COUNT(*) — does NOT load all data
        return $collection->getSize();
    }
}

Ein wichtiges Detail: Wenn man eine Collection mit foreach durchläuft und danach noch einmal getItems() aufruft, wird der SQL-Query nicht erneut ausgeführt — die Daten sind bereits im Speicher. Das ist effizient, kann aber zu Verwirrung führen wenn man nach dem ersten Laden weitere Filter hinzufügt. In diesem Fall würden die neuen Filter die bereits geladenen Daten nicht beeinflussen. Man muss dann clear() aufrufen, um den geladenen Zustand zurückzusetzen.

EAV vs. Flat: addAttributeToFilter vs. addFieldToFilter

Eine der häufigsten Fehlerquellen bei Magento 2 Collections ist die Verwechslung der beiden Filtermethoden addAttributeToFilter() und addFieldToFilter(). Diese Methoden sehen ähnlich aus, funktionieren aber fundamental unterschiedlich und werden für verschiedene Tabellentypen verwendet.

addAttributeToFilter() ist für EAV-Collections (Entity-Attribute-Value). Im EAV-System werden Attribute nicht als direkte Spalten in der Haupttabelle gespeichert, sondern in separaten Value-Tabellen (eine pro Datentyp: catalog_product_entity_varchar, catalog_product_entity_decimal usw.). Ein Filter auf ein EAV-Attribut erfordert deshalb einen JOIN auf die entsprechende Value-Tabelle. addAttributeToFilter() übernimmt diesen JOIN automatisch und fügt das WHERE-Kriterium für den richtigen Attribut-Code hinzu. Beispiele: alle Catalog Collections (Products, Categories) und Customer Collections.

addFieldToFilter() ist für Flat-Collections — also Tabellen, bei denen jede Eigenschaft eine direkte Spalte in der Haupttabelle ist. Sales-Entities (Orders, Invoices, Shipments, Credit Memos), Quotes und alle eigenen Module mit normalen MySQL-Tabellen verwenden flat Collections. Ein Filter mit addFieldToFilter() fügt direkt ein WHERE-Kriterium zur Haupttabelle hinzu, ohne JOINs. Das macht flat Collections in der Regel schneller als EAV-Collections.

Was passiert, wenn man die falsche Methode verwendet? addFieldToFilter() auf einer EAV-Collection funktioniert nur wenn das Feld tatsächlich eine direkte Spalte der Haupttabelle ist (z.B. entity_id, sku, created_at). Für EAV-Attribute muss man addAttributeToFilter() verwenden, da es sonst zu SQL-Fehlern oder falschen Ergebnissen kommt. Eigene Collections (für eigene Module) verwenden fast immer addFieldToFilter(), weil eigene Tabellen in der Regel flat sind.

Collections filtern, sortieren und paginieren

Die Magento 2 Collection API bietet eine fluent Interface für alle gängigen Datenbankoperationen. Filter werden mit Condition-Arrays übergeben, die dem Zend_Db_Select-Format folgen. Die gängigsten Conditions sind: eq (equals), neq (not equals), like, nlike, in, nin (not in), is, notnull, null, gt (greater than), lt (less than), gteq (greater than or equal), lteq (less than or equal), from/to für Datumsbereiche.

<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Model;

use Mironsoft\Blog\Model\ResourceModel\Post\CollectionFactory;

/**
 * Blog post query service — demonstrates collection filtering patterns.
 */
class PostQueryService
{
    public function __construct(
        private readonly CollectionFactory $collectionFactory
    ) {}

    /**
     * Get posts filtered by multiple criteria.
     *
     * @return \Mironsoft\Blog\Model\Post[]
     */
    public function getFilteredPosts(
        array $categoryIds,
        string $fromDate,
        string $toDate,
        int $page = 1,
        int $pageSize = 20
    ): array {
        $collection = $this->collectionFactory->create();

        $collection
            // Filter by multiple category IDs (IN condition)
            ->addFieldToFilter('category_id', ['in' => $categoryIds])
            // Filter by date range
            ->addFieldToFilter('publish_date', ['from' => $fromDate, 'to' => $toDate])
            // Only active posts
            ->addFieldToFilter('is_active', ['eq' => 1])
            // Sort by newest first
            ->setOrder('publish_date', \Magento\Framework\Data\Collection::SORT_ORDER_DESC)
            // Pagination
            ->setPageSize($pageSize)
            ->setCurPage($page);

        return $collection->getItems();
    }

    /**
     * Get pagination metadata — only COUNT query, no data loaded.
     */
    public function getPaginationData(array $categoryIds): array
    {
        $collection = $this->collectionFactory->create();
        $collection->addFieldToFilter('category_id', ['in' => $categoryIds]);
        $collection->addFieldToFilter('is_active', ['eq' => 1]);

        $totalCount = $collection->getSize(); // SELECT COUNT(*) only

        return [
            'total_count' => $totalCount,
            'last_page' => $collection->getLastPageNumber(),
        ];
    }
}

Die Methode getSize() ist besonders wichtig für performante Paginierung. Sie führt ein separates SELECT COUNT(*) aus, ohne die eigentlichen Daten zu laden. Das ermöglicht es, die Gesamtanzahl der Ergebnisse für eine Paginierungsanzeige zu ermitteln, ohne alle Datensätze in den Speicher zu laden. In Kombination mit setPageSize() und setCurPage() bildet getSize() die Grundlage für effiziente Paginierung in Magento 2.4.8.

Eigene Collection implementieren

Für ein eigenes Magento 2 Modul muss man eine eigene Collection-Klasse erstellen, die AbstractCollection erweitert. Diese Klasse konfiguriert die Verbindung zwischen Model und ResourceModel und kann eigene Filtermethoden als fluent API anbieten. Die Konvention für den Speicherort ist Model/ResourceModel/EntityName/Collection.php.

<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Model\ResourceModel\Post;

use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use Mironsoft\Blog\Model\Post;
use Mironsoft\Blog\Model\ResourceModel\Post as PostResource;

/**
 * Blog post collection — implements Iterator pattern via AbstractCollection.
 *
 * @extends AbstractCollection<Post>
 */
class Collection extends AbstractCollection
{
    /** @var string Primary key field name */
    protected $_idFieldName = 'post_id';

    /** @var string Event prefix for collection events */
    protected $_eventPrefix = 'mironsoft_blog_post_collection';

    /** @var string Event object identifier */
    protected $_eventObject = 'post_collection';

    /**
     * Initialize collection — link to Model and ResourceModel.
     */
    protected function _construct(): void
    {
        $this->_init(Post::class, PostResource::class);
    }

    /**
     * Add filter for active posts only.
     */
    public function addActiveFilter(): static
    {
        $this->addFieldToFilter('is_active', ['eq' => 1]);
        return $this;
    }

    /**
     * Filter posts published before a given date.
     */
    public function addPublishedFilter(?string $date = null): static
    {
        $date ??= date('Y-m-d H:i:s');
        $this->addFieldToFilter('publish_date', ['lteq' => $date]);
        return $this;
    }

    /**
     * Add store filter for multi-store setups.
     */
    public function addStoreFilter(int $storeId): static
    {
        $this->getSelect()->where(
            'main_table.store_id IN (0, ?)',
            $storeId
        );
        return $this;
    }

    /**
     * Join author information from admin_user table.
     */
    public function joinAuthorName(): static
    {
        $this->getSelect()->joinLeft(
            ['author' => $this->getTable('admin_user')],
            'main_table.author_id = author.user_id',
            ['author_name' => new \Zend_Db_Expr("CONCAT(author.firstname, ' ', author.lastname)")]
        );
        return $this;
    }
}

Die Collection kann dann sehr lesbar verwendet werden, weil die eigenen Methoden eine domänenspezifische Sprache bilden. Statt kryptischer Filter-Arrays schreibt man $collection->addActiveFilter()->addPublishedFilter()->addStoreFilter(1). Diese fluent API ist ein wichtiger Aspekt guter Magento 2 Architektur: Collection-Logik gehört in die Collection, nicht in den aufrufenden Code.

SearchCriteria und SearchResults als moderne Alternative

Während direkte Collections im internen Code ihre Berechtigung haben, setzt das Magento 2 Service-Contract-System auf SearchCriteria als abstrakte Abfragesprache. SearchCriteria ist ein Value Object, das Filter, Sortierungen und Pagination kapselt, ohne an eine konkrete Collection oder Datenbanktabelle gebunden zu sein. Es ist die Sprache, in der Repository-Clients mit Repositories kommunizieren.

Der Vorteil von SearchCriteria ist die vollständige Testbarkeit. Man kann ein SearchCriteria-Objekt erstellen, an ein gemocktes Repository übergeben, und das gemockte Repository gibt SearchResults zurück — ohne einen einzigen Datenbankzugriff. Im Gegensatz dazu erfordert das direkte Testen einer Collection einen Datenbankzugriff oder komplexes Mocking des ResourceModels.

SearchResults ist das entsprechende Ergebniscontainer-Interface: getItems() gibt die geladenen Entities zurück, getTotalCount() gibt die Gesamtanzahl zurück (für Pagination). SearchResults ist ein reines Value Object ohne SQL-Kenntnis. Das Repository ist dafür verantwortlich, SearchCriteria in Collection-Operationen zu übersetzen und die Ergebnisse in SearchResults zu verpacken.

Die Verwendung von SearchCriteria ist besonders für REST-API-Endpunkte in Magento 2 relevant: Wenn ein Repository Interface mit getList(SearchCriteriaInterface $criteria) über die REST-API aufgerufen wird, wandelt Magento die Query-Parameter der URL automatisch in ein SearchCriteria-Objekt um. Das bedeutet: Ein korrekt implementiertes Repository ist automatisch über die REST-API erreichbar, ohne zusätzlichen Code.

Collection-Queries debuggen

Das Debuggen von Collection-Queries ist ein unverzichtbares Handwerk für Magento 2 Entwickler. Der direkteste Weg ist (string) $collection->getSelect(), das den vollständigen SQL-Statement als String zurückgibt. Man kann diesen String dann in ein Datenbank-Tool wie MySQL Workbench oder TablePlus einfügen und analysieren. Das zeigt sowohl die WHERE-Klauseln als auch alle JOINs, ORDER BY und LIMIT-Anweisungen.

$collection->printLogQuery(true) gibt den Query direkt in die PHP-Ausgabe aus. Das ist nützlich bei der schnellen Entwicklung, sollte aber im Produktionscode nie vorhanden sein. Eine sauberere Alternative für Entwicklung ist das Schreiben des Query-Strings in das Magento-Log: $this->logger->debug('Collection SQL: ' . $collection->getSelect()).

In der Entwicklungsumgebung kann bin/magento dev:query-log:enable alle Datenbankabfragen in var/log/db.log protokollieren. Das gibt einen vollständigen Überblick über alle SQL-Queries einer Seite, inklusive deren Ausführungszeit. Das ist besonders hilfreich um N+1-Query-Probleme zu erkennen: Wenn man 50 ähnliche Queries sieht, hat man höchstwahrscheinlich eine Collection, die in einer Schleife einzelne load()-Aufrufe macht.

Performance-Tipps für Collections

Der wichtigste Performance-Tipp für Collections: Immer nur die benötigten Felder laden. Bei EAV-Collections mit addAttributeToSelect() und bei Flat-Collections mit einem expliziten getSelect()->columns([...]). Ein SELECT * bei einem Katalogprodukt in Magento 2 kann Dutzende von EAV-JOINs auslösen — das ist teuer. Wenn man nur den Titel und die URL eines Produkts braucht, sollte man auch nur diese beiden Attribute laden.

Für Paginierung gilt: Immer getSize() vor dem Laden der Daten aufrufen, wenn man die Gesamtanzahl benötigt. getSize() führt nur ein SELECT COUNT(*) aus, ohne die Entities zu laden. Wenn man getSize() nach getItems() oder foreach aufruft, sind die Daten bereits im Speicher — das COUNT wird dann aus den geladenen Daten berechnet, was korrekt ist, aber möglicherweise nicht das, was man will wenn man separate Paginierung und Datenladen optimieren möchte.

Collections, die für Exporte oder Batch-Prozesse über große Datenmengen iterieren, sollten nicht alle Daten auf einmal in den Speicher laden. Die empfohlene Strategie ist das seitenweise Laden: Man iteriert über alle Seiten, lädt jeweils nur eine Seite mit setPageSize() und setCurPage(), verarbeitet die Daten und gibt den Speicher frei. In Magento 2.4.8 gibt es dafür auch Walk-Methoden und Stream-basierte Ansätze über Custom ResourceModel-Methoden.

Datenbank-Indizes sind für Collection-Performance entscheidend. Wenn man häufig nach einem bestimmten Feld filtert (z.B. is_active, store_id, publish_date), sollte dieses Feld in der db_schema.xml mit einem INDEX versehen werden. Ohne Index führt MySQL einen Full Table Scan aus, der bei wachsenden Datensätzen exponentiell langsamer wird. Das deklarative Schema in Magento 2.4.8 macht es einfach, Indizes korrekt zu deklarieren und über Patches zu verwalten.

Zusammenfassung: Iterator & Collections in Magento 2

Das Iterator Pattern steckt tief in Magento 2 Collections. AbstractCollection implementiert IteratorAggregate und lädt Daten lazy. EAV-Collections nutzen addAttributeToFilter() mit automatischen JOINs, Flat-Collections addFieldToFilter() mit direktem WHERE. getSize() für COUNT ohne Datenladen. SearchCriteria für Service Contracts. Eigene Collections mit fluent API für domänenspezifische Filter.

Lazy Loading

SQL erst bei erstem Zugriff. Trigger: foreach, getItems(), getSize(), count(). Danach gecacht. clear() setzt den Zustand zurück.

EAV vs. Flat

addAttributeToFilter() für Catalog/Customer. addFieldToFilter() für Sales/eigene Module. Falsche Methode = SQL-Fehler oder falsche Ergebnisse.

SearchCriteria

Für Repository-Interfaces, REST-API, testbaren Code. SearchCriteriaBuilder + SortOrderBuilder. SearchResults mit getItems() und getTotalCount().

Pagination

setPageSize() + setCurPage(). getSize() für COUNT ohne Datenladen. getLastPageNumber() für Gesamtseitenzahl.

Mironsoft

Eigene Collections und Repositories entwickeln?

Wir entwickeln performante Magento 2 Module mit korrektem Collection-Design, SearchCriteria-Integration und vollständigem Repository Pattern. Sauber, testbar, upgrade-sicher.

Custom Collections

Eigene Collections mit fluent Filter-API und Store-Scope-Support

Repository Pattern

Service Contracts mit SearchCriteria, SearchResults und Interface-first

Performance

Query-Optimierung, Index-Analyse und Lazy-Loading-Strategien

FAQ: Iterator & Collections in Magento 2

1 Was ist das Iterator Pattern in Magento 2?

Einheitliche Schnittstelle zum Durchlaufen von Objektsammlungen unabhängig von der internen Datenstruktur. Magento 2 Collections implementieren IteratorAggregate, sodass foreach direkt auf Collections angewendet werden kann. Der SQL-Query wird dabei lazy ausgeführt.

2 Wann wird eine Magento 2 Collection geladen?

Lazy Loading: Trigger sind foreach, getItems(), getSize(), count() oder explizites load(). Danach ist isLoaded=true und SQL wird nicht erneut ausgeführt. clear() setzt den Zustand zurück.

3 addAttributeToFilter vs. addFieldToFilter?

addAttributeToFilter() für EAV-Tabellen (Catalog, Customer) — löst JOINs auf EAV-Value-Tabellen auf. addFieldToFilter() für Flat-Tabellen (Orders, eigene Module) — direktes WHERE auf Haupttabelle. Falsche Methode führt zu SQL-Fehlern.

4 Wie erstelle ich eine eigene Collection?

AbstractCollection erweitern. In _construct(): _init(Model::class, ResourceModel::class). $_idFieldName setzen. Liegt in Model/ResourceModel/EntityName/Collection.php. Eigene fluent Filtermethoden hinzufügen.

5 Wie funktioniert Pagination mit Collections?

setPageSize(20) + setCurPage(1). getSize() für COUNT ohne alle Daten zu laden. getLastPageNumber() berechnet letzte Seite. Für Repositories: SearchCriteriaBuilder::setPageSize() und setCurrentPage().

6 Collection vs. SearchResults?

Collection = internes DB-Abfrage-Objekt mit SQL-Zugriff. SearchResults = Value Object vom Repository mit getItems() und getTotalCount(). Kein SQL-Wissen, vollständig testbar ohne Datenbankzugriff.

7 Wann SearchCriteria statt direkte Collection?

SearchCriteria: Repository Interfaces, REST-API, testbarer Code, Service Contracts. Direkte Collections: Performance-kritische Batch-Prozesse, CLI-Commands, komplexe JOINs die SearchCriteria nicht abbilden kann.

8 Wie debugge ich Collection-Queries?

(string) $collection->getSelect() gibt SQL aus. printLogQuery(true) direkt in Ausgabe. In Dev: bin/magento dev:query-log:enable für alle Queries. $this->logger->debug($collection->getSelect()) für Log-Ausgabe.

9 Wie filtere ich nach mehreren Werten (IN)?

addFieldToFilter('status', ['in' => ['pending', 'processing']]). Für NOT IN: ['nin' => [...]]. Mehrere addFieldToFilter()-Aufrufe werden mit AND verknüpft. OR-Verknüpfung: Array mit mehreren Conditions als Wert.

10 Wie iteriere ich ohne foreach?

getItems() Array aller Items. getFirstItem() erstes Element. getItemById($id) spezifisches Item. getColumnValues('field') Array aller Feldwerte. walk(callable) führt Funktion für jedes Item aus.