ITER
Deep Dive · Magento 2 Design Patterns

Iterator Pattern:
Collections und Varien_Data_Collection

Wie Magento's AbstractCollection PHP-Iteratoren implementiert, warum Lazy Loading entscheidend ist, und wie du große Katalogdaten memory-effizient traversierst.

13 min Lesezeit
Magento 2.4.8 · PHP 8.4
GoF Behavioral Pattern

Das Iterator Pattern ist eines der häufigst genutzten GoF-Patterns in Magento — du verwendest es jeden Tag, wenn du über eine Collection iterierst. foreach ($productCollection as $product) — das ist Iterator Pattern in Aktion. Aber was passiert intern? Wann wird die DB-Query ausgeführt? Und wie vermeidest du Memory-Overflows bei 100.000 Produkten? Dieses Deep Dive zeigt die vollständige Kette von Varien_Data_Collection bis zu PHP-Generators.

1. GoF Iterator Pattern: Das Interface

Das GoF Iterator Pattern definiert ein Interface, das sequenziellen Zugriff auf Elemente einer Sammlung ermöglicht, ohne die interne Repräsentation offenzulegen.


+------------------+          uses          +------------------+
|     Client       | ---------------------->|    Iterator      |
|                  |                        |  + current()     |
|  foreach(coll)   |                        |  + key()         |
+------------------+                        |  + next()        |
                                            |  + rewind()      |
                                            |  + valid()       |
                                            +------------------+
                                                    ▲
                                                    |
                                    +----------------------------------+
                                    | Magento AbstractCollection       |
                                    | implements Iterator + Countable  |
                                    +----------------------------------+
    

Die vier Vorteile des Iterator Patterns:

  • Entkopplung: Der Client muss nicht wissen, ob es ein Array, eine DB-Query oder ein Remote-Stream ist
  • Lazy Evaluation: Daten werden erst bei Bedarf geladen
  • Einheitliche API: foreach funktioniert für alle Collections gleich
  • Composability: Iteratoren können dekoriert und kombiniert werden

2. PHP Iterator Interface und IteratorAggregate

PHP stellt zwei Wege bereit, um Objekte mit foreach traversierbar zu machen:


<?php

// Option 1: Iterator — Klasse implementiert Iterator direkt
// 5 Methoden müssen implementiert werden
class DirectIterator implements \Iterator
{
    private int $position = 0;
    private array $data = [];

    public function current(): mixed    { return $this->data[$this->position]; }
    public function key(): int          { return $this->position; }
    public function next(): void        { $this->position++; }
    public function rewind(): void      { $this->position = 0; }
    public function valid(): bool       { return isset($this->data[$this->position]); }
}

// Option 2: IteratorAggregate — delegiert an einen Iterator
// Sauberer wenn die Klasse selbst keine Iteration darstellt
class Collection implements \IteratorAggregate
{
    private array $items = [];

    public function getIterator(): \ArrayIterator
    {
        return new \ArrayIterator($this->items);
    }
}

// Option 3: Generator-basiert (PHP 5.5+)
// Laziest approach — perfekt für große Datensätze
function yieldProducts(array $ids): \Generator
{
    foreach ($ids as $id) {
        yield $id => loadProduct($id); // Lazy: nur bei Bedarf geladen
    }
}
    

3. Varien_Data_Collection: Das Fundament

Magento\Framework\Data\Collection (früher Varien_Data_Collection) ist die Basisklasse aller Magento-Collections. Sie implementiert Iterator, Countable und ArrayAccess:


<?php

// vendor/magento/framework/Data/Collection.php (vereinfacht)
namespace Magento\Framework\Data;

use Magento\Framework\DataObject;

class Collection implements \IteratorAggregate, \Countable, \ArrayAccess
{
    /** @var DataObject[] Internal storage — the actual pool of items */
    protected array $_items = [];

    private int $_curPos = 0;
    private bool $_isCollectionLoaded = false;

    /**
     * PHP IteratorAggregate — used by foreach
     * Triggers load() if not yet loaded
     */
    public function getIterator(): \ArrayIterator
    {
        $this->load();
        return new \ArrayIterator($this->_items);
    }

    /**
     * Add an item to the collection.
     */
    public function addItem(DataObject $item): static
    {
        $itemId = $this->_getItemId($item);
        if ($itemId !== null) {
            $this->_items[$itemId] = $item;
        } else {
            $this->_items[] = $item;
        }
        return $this;
    }

    /**
     * Load collection — base implementation is a no-op
     * Overridden in ResourceModel\Collection to run SQL
     */
    public function load(bool $printQuery = false, bool $logQuery = false): static
    {
        $this->_isCollectionLoaded = true;
        return $this;
    }

    /**
     * Countable interface
     */
    public function count(): int
    {
        $this->load();
        return count($this->_items);
    }

    /**
     * Get all items as array
     */
    public function getItems(): array
    {
        $this->load();
        return $this->_items;
    }
}
    

4. Magento AbstractCollection und Lazy Loading

Die DB-gebundene Collection erbt von Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection und überschreibt load(), um eine SQL-Query auszuführen — aber erst beim ersten Zugriff:


<?php

// vendor/magento/framework/Model/ResourceModel/Db/Collection/AbstractCollection.php

namespace Magento\Framework\Model\ResourceModel\Db\Collection;

use Magento\Framework\Data\Collection\AbstractDb;
use Magento\Framework\DB\Select;

abstract class AbstractCollection extends AbstractDb
{
    protected bool $_isCollectionLoaded = false;

    /**
     * LAZY LOADING: SQL wird erst hier ausgeführt
     * Aufgerufen bei: foreach, count(), getItems(), getFirstItem()
     */
    public function load(bool $printQuery = false, bool $logQuery = false): static
    {
        if ($this->isLoaded()) {
            return $this; // Already loaded — return immediately
        }

        $this->_beforeLoad();
        $this->_renderFilters();
        $this->_renderOrders();
        $this->_renderLimit();

        // THIS is where the SQL query executes
        $this->printLogQuery($printQuery, $logQuery);
        $data = $this->getData(); // ← SELECT * FROM ... WHERE ... LIMIT ...

        $this->resetData();
        if (is_array($data)) {
            foreach ($data as $row) {
                $item = $this->getNewEmptyItem();
                $item->setData($row); // Hydrate model with row data
                $this->addItem($item);
                // Dispatch event for each item
            }
        }

        $this->_setIsLoaded();
        $this->_afterLoad();
        return $this;
    }
}
    

Das kritische Verständnis: Die Collection ist ein Query-Builder, kein Array. Erst beim ersten foreach, count() oder getItems() trifft die SQL-Query die Datenbank.


<?php

declare(strict_types=1);

use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;

// Diese Zeile führt KEINE DB-Query aus!
$collection = $collectionFactory->create();

// Diese Zeilen konfigurieren nur den Query-Builder:
$collection->addAttributeToSelect(['name', 'price', 'sku'])
    ->addAttributeToFilter('status', 1)
    ->addAttributeToFilter('visibility', ['in' => [3, 4]])
    ->setOrder('created_at', 'DESC')
    ->setPageSize(20)
    ->setCurPage(1);

// ERST HIER wird die SQL-Query ausgeführt:
foreach ($collection as $product) { // ← load() triggered here
    echo $product->getName();
}

// Zweites foreach: kein zweites DB-Query, Daten sind gecacht
foreach ($collection as $product) { // ← uses cached $_items
    echo $product->getSku();
}
    

5. Collection-Filter, Joins und Paginierung

Die AbstractCollection ist ein mächtiger Query-Builder. Die wichtigsten Methoden:


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Model;

use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;
use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection;

/**
 * Demonstrates collection filtering, joining, and pagination.
 */
final class ProductCollectionBuilder
{
    public function __construct(
        private readonly CollectionFactory $collectionFactory,
    ) {}

    /**
     * Builds a filtered, joined, paginated product collection.
     */
    public function getFilteredProducts(int $categoryId, int $page = 1): ProductCollection
    {
        $collection = $this->collectionFactory->create();

        // Select only needed attributes (avoids loading all EAV attributes)
        $collection->addAttributeToSelect(['name', 'price', 'sku', 'thumbnail']);

        // EAV attribute filter
        $collection->addAttributeToFilter('status', ['eq' => 1]);
        $collection->addAttributeToFilter('visibility', ['in' => [3, 4]]);

        // Price range filter
        $collection->addAttributeToFilter('price', ['gteq' => 10.00, 'lteq' => 500.00]);

        // Category filter via join
        $collection->addCategoriesFilter(['in' => [$categoryId]]);

        // Stock filter
        $collection->joinField(
            'is_in_stock',
            'cataloginventory_stock_item',
            'is_in_stock',
            'product_id=entity_id',
            '{{table}}.stock_id=1',
            'left'
        );
        $collection->addFieldToFilter('is_in_stock', 1);

        // Sorting
        $collection->addAttributeToSort('position', 'ASC');
        $collection->addAttributeToSort('created_at', 'DESC');

        // Pagination — crucial for large catalogs
        $collection->setPageSize(24);
        $collection->setCurPage($page);

        return $collection; // Still not loaded — lazy!
    }
}
    

6. Chunked Iterator: Große Kataloge ohne Memory-Overflow

Das häufigste Performance-Problem: 100.000 Produkte auf einmal laden. Die Lösung ist ein Chunked Iterator, der die Collection in Pages aufteilt:


<?php

declare(strict_types=1);

namespace Mironsoft\Import\Iterator;

use Magento\Catalog\Model\ResourceModel\Product\Collection;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;

/**
 * Memory-efficient chunked iterator for large product collections.
 * Loads only one page at a time — prevents PHP OOM errors.
 */
final class ChunkedProductIterator implements \IteratorAggregate
{
    private const CHUNK_SIZE = 1000;

    public function __construct(
        private readonly CollectionFactory $collectionFactory,
    ) {}

    /**
     * Yields products in chunks — never loads all products at once.
     *
     * @return \Generator<int, \Magento\Catalog\Model\Product>
     */
    public function getIterator(): \Generator
    {
        $page = 1;
        $totalYielded = 0;

        do {
            $collection = $this->createPage($page);
            $count = $collection->count();

            if ($count === 0) {
                break;
            }

            foreach ($collection as $product) {
                yield $totalYielded => $product;
                $totalYielded++;
            }

            // Critical: free memory after each chunk
            $collection->clear();
            unset($collection);

            $page++;

        } while ($count === self::CHUNK_SIZE);
    }

    private function createPage(int $page): Collection
    {
        $collection = $this->collectionFactory->create();
        $collection->addAttributeToSelect(['name', 'sku', 'price']);
        $collection->setPageSize(self::CHUNK_SIZE);
        $collection->setCurPage($page);
        return $collection;
    }
}
    

Verwendung im Import-Service:


<?php

declare(strict_types=1);

namespace Mironsoft\Import\Service;

use Mironsoft\Import\Iterator\ChunkedProductIterator;

/**
 * Processes all products in chunks — memory-safe.
 */
final class ProductExportService
{
    public function __construct(
        private readonly ChunkedProductIterator $iterator,
    ) {}

    /**
     * Exports all products to CSV without loading all at once.
     */
    public function exportToCsv(string $outputPath): void
    {
        $file = fopen($outputPath, 'w');
        fputcsv($file, ['SKU', 'Name', 'Price']);

        $count = 0;
        foreach ($this->iterator as $position => $product) {
            fputcsv($file, [
                $product->getSku(),
                $product->getName(),
                $product->getPrice(),
            ]);
            $count++;

            // Memory check every 10,000 products
            if ($count % 10000 === 0) {
                echo sprintf(
                    "Processed %d products, memory: %s MB\n",
                    $count,
                    round(memory_get_usage(true) / 1024 / 1024, 2)
                );
            }
        }

        fclose($file);
        echo "Exported {$count} products total.\n";
    }
}
    

7. PHP Generators als lazy Iteratoren

PHP Generators (seit 5.5) sind der eleganteste Weg, lazy Iteratoren zu implementieren:


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Iterator;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SortOrderBuilder;

/**
 * Generator-based lazy product iterator using Service Contracts.
 * Preferred over direct Collection access for API-layer code.
 */
final class ProductIteratorGenerator
{
    private const PAGE_SIZE = 500;

    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
        private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
        private readonly SortOrderBuilder $sortOrderBuilder,
    ) {}

    /**
     * Lazily yields all active products using SearchCriteria pagination.
     *
     * @return \Generator<string, \Magento\Catalog\Api\Data\ProductInterface>
     */
    public function iterateActive(): \Generator
    {
        $sortOrder = $this->sortOrderBuilder
            ->setField('entity_id')
            ->setAscendingDirection()
            ->create();

        $page = 1;

        do {
            $criteria = $this->searchCriteriaBuilder
                ->addFilter('status', 1)
                ->addSortOrder($sortOrder)
                ->setPageSize(self::PAGE_SIZE)
                ->setCurrentPage($page)
                ->create();

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

            foreach ($items as $product) {
                yield $product->getSku() => $product;
            }

            $page++;

        } while (count($items) === self::PAGE_SIZE);
    }
}
    

Was Generators besonders mächtig macht — sie können bidirektional kommunizieren:


<?php

// Generator mit send() — ermöglicht Feedback vom Consumer
function* batchProcessor(): \Generator
{
    $processed = 0;
    while (true) {
        $item = yield $processed; // Yield current count, receive next item
        if ($item === null) break;
        process($item);
        $processed++;
    }
}

$gen = batchProcessor();
$gen->current(); // Start generator
$gen->send($product1); // Send item, generator processes it
$gen->send($product2);
$gen->send(null); // Signal done
    

8. Eigener Custom-Iterator für Magento

Manchmal brauchst du einen Iterator, der nicht an einer DB-Collection hängt — z.B. für CSV-Dateien oder externe APIs:


<?php

declare(strict_types=1);

namespace Mironsoft\Import\Iterator;

use Magento\Framework\DataObject;

/**
 * Lazy CSV iterator — reads one line at a time without loading entire file.
 * Memory usage: O(1) regardless of file size.
 */
final class CsvProductIterator implements \Iterator
{
    private mixed $fileHandle = null;
    private array $currentRow = [];
    private int $position = 0;
    private array $headers = [];

    public function __construct(
        private readonly string $filePath,
    ) {}

    public function rewind(): void
    {
        if ($this->fileHandle !== null) {
            fclose($this->fileHandle);
        }

        $this->fileHandle = fopen($this->filePath, 'r');
        $this->position = 0;

        // Read header row
        $this->headers = fgetcsv($this->fileHandle) ?: [];

        // Read first data row
        $this->next();
    }

    public function current(): DataObject
    {
        return new DataObject(array_combine($this->headers, $this->currentRow));
    }

    public function key(): int
    {
        return $this->position;
    }

    public function next(): void
    {
        $row = fgetcsv($this->fileHandle);
        $this->currentRow = $row !== false ? $row : [];
        $this->position++;
    }

    public function valid(): bool
    {
        return !empty($this->currentRow)
            && count($this->currentRow) === count($this->headers);
    }

    public function __destruct()
    {
        if ($this->fileHandle !== null) {
            fclose($this->fileHandle);
        }
    }
}
    

Verwendung:


<?php

$iterator = new CsvProductIterator('/var/import/products.csv');

foreach ($iterator as $lineNumber => $row) {
    echo $row->getData('sku') . ': ' . $row->getData('name') . PHP_EOL;
    // Memory: ~4KB regardless of whether file has 100 or 1,000,000 rows
}
    

9. Iterator vs. getItems(): Performance-Vergleich

Ansatz Memory Queries Best for
foreach ($collection as $item) Alle Items in RAM 1 SQL <10.000 Items, normale Pages
getItems() Alle Items in RAM 1 SQL Wenn Array-Zugriff nötig
Chunked Iterator (eigener) Nur 1 Chunk in RAM N SQL (N = total/chunk) Batch-Jobs, Imports/Exports
Generator + SearchCriteria Nur 1 Page in RAM N SQL Service-Layer-Code
CSV-Iterator O(1) — 1 Zeile 0 SQL File-basierter Import

Typischer Fehler beim Iterieren großer Collections:


<?php

// FALSCH: Lädt ALLE 500.000 Produkte auf einmal in den RAM
$collection = $collectionFactory->create();
// Kein setPageSize() → PHP memory_limit überschritten

foreach ($collection as $product) { // Fatal: Allowed memory exhausted
    processProduct($product);
}

// RICHTIG: Chunk-basiert mit setPageSize()
$collection = $collectionFactory->create();
$collection->setPageSize(1000);

// Oder noch besser: ChunkedProductIterator (siehe oben)
foreach ($chunkedIterator as $product) {
    processProduct($product);
}
    

10. Fazit: Collections richtig nutzen

Das Iterator Pattern ist in Magento omnipräsent — jede Collection ist ein Iterator. Die wichtigsten Regeln:

✓ Best Practices

  • Immer setPageSize() setzen
  • Nur benötigte Attribute selektieren (addAttributeToSelect)
  • Für Batch-Jobs: Chunked Iterator verwenden
  • Collection vor forEach-Konfiguration vollständig aufbauen
  • Generators für lazy Pipelines bevorzugen

✗ Anti-Patterns

  • Kein setPageSize() bei großen Tabellen
  • addAttributeToSelect('*') in Loops
  • Collection im Constructor laden (zu früh)
  • Nach load() noch Filter hinzufügen
  • Count-Query vor und nach Filter-Änderung

Zusammenfassung

Pattern
Iterator ermöglicht sequenziellen Zugriff auf Collections ohne interne Struktur offenzulegen — jedes foreach in Magento nutzt es
Lazy Loading
AbstractCollection führt SQL erst beim ersten foreach/count() aus — konfiguriere den Query-Builder vollständig vor dem Iterieren
Memory
Für >10.000 Items immer Chunked Iterator oder Generator verwenden — verhindert OOM-Fehler bei großen Katalogen
Generators
PHP Generators sind die eleganteste Form lazier Iteratoren — ideal für Pipelines über SearchCriteria oder externe Datenquellen

Collection-Performance optimieren

Collections analysieren, Memory-Probleme lösen oder Batch-Importe optimieren.

????
Collection-Audit
Slow-Query-Analyse und N+1-Problem-Erkennung in Collections
Batch-Import
Memory-effizienter Import großer Produktkataloge ohne OOM
????
Generator-Pipeline
Lazy-Loading-Pipelines für ETL-Prozesse und Export-Services

Häufige Fragen zum Iterator Pattern in Magento

Was ist das Iterator Pattern in Magento? +
Das Iterator Pattern ermöglicht sequenziellen Zugriff auf Elemente einer Collection, ohne die interne Repräsentation offenzulegen. Alle Magento-Collections implementieren das PHP-Iterator-Interface, wodurch foreach-Schleifen transparent funktionieren.
Wann führt Magento die SQL-Query einer Collection aus? +
Magento nutzt Lazy Loading: Die SQL-Query wird erst beim ersten Datenzugriff ausgeführt — beim ersten foreach, count(), getItems() oder getFirstItem(). Filter und Sortierung vorher konfigurieren fügt nur Bedingungen zum Query-Builder hinzu.
Wie verhindere ich Memory-Overflow bei großen Collections? +
Immer setPageSize() setzen und für große Datensätze einen Chunked Iterator verwenden, der die Collection seitenweise lädt. Nach jeder Seite $collection->clear() und unset($collection) aufrufen um RAM freizugeben.
Was ist der Unterschied zwischen Varien_Data_Collection und AbstractCollection? +
Varien_Data_Collection ist die Basis ohne DB-Verbindung. AbstractCollection erweitert diese mit echtem Lazy Loading und SQL-Query-Ausführung via Zend_Db.
Wann sollte ich PHP Generators statt Collections verwenden? +
Generators eignen sich wenn die Datenquelle keine Magento-Collection ist (CSV, API), du eine lazy Pipeline aufbauen möchtest oder der Speicherverbrauch O(1) bleiben soll.
Kann ich nach dem ersten foreach weitere Filter hinzufügen? +
Nein. Sobald die Collection geladen ist, werden weitere Filter ignoriert. Um neue Filter anzuwenden, clear() aufrufen, neue Filter setzen und neu iterieren.
Was macht addAttributeToSelect('*') in Magento? +
addAttributeToSelect('*') lädt alle EAV-Attribute und erzeugt viele JOIN-Operationen, was zu extrem langsamen Queries führt. Besser: nur benötigte Attribute selektieren.
Wie implementiert Magento das Iterator-Interface intern? +
Magento\\Framework\\Data\\Collection implementiert IteratorAggregate und gibt in getIterator() einen ArrayIterator über das interne $_items-Array zurück. Dabei wird load() aufgerufen wenn noch nicht geladen.
Warum ist collection->count() manchmal langsam? +
count() vor dem Laden führt eine separate COUNT(*)-Query aus. Danach beim foreach folgt die Daten-Query. Das verursacht 2 statt 1 DB-Query. Collection erst iterieren, dann count() aufrufen.
Wie unterscheidet sich ein CSV-Iterator von einer Magento-Collection? +
Ein CSV-Iterator liest Zeile für Zeile mit konstantem O(1) Speicherverbrauch. Eine Magento-Collection lädt beim ersten Zugriff alle Ergebnisse in ein PHP-Array. Für riesige Imports ist ein eigener Iterator deutlich speichereffizienter.