Iterator Pattern in Magento 2: Collections and Varien_Data_Collection | Mironsoft
AI generated
ITER
Deep Dive · Magento 2 Design Patterns

Iterator Pattern:
Collections and Varien_Data_Collection

How Magento's AbstractCollection implements PHP iterators, why lazy loading matters, and how to traverse large catalog data in a memory efficient way.

13 min read
Magento 2.4.8 · PHP 8.4
GoF Behavioral Pattern

The Iterator Pattern is one of the most commonly used GoF patterns in Magento, you use it every day whenever you iterate over a collection. foreach ($productCollection as $product) is the Iterator Pattern in action. But what happens internally? When does the DB query actually run? And how do you avoid memory overflows with 100,000 products? This deep dive walks through the full chain from Varien_Data_Collection to PHP generators.

1. GoF Iterator Pattern: the interface

The GoF Iterator Pattern defines an interface that provides sequential access to the elements of a collection without exposing its internal representation.


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

The four advantages of the Iterator Pattern:

  • Decoupling: the client doesn't need to know whether it's an array, a DB query or a remote stream
  • Lazy evaluation: data is only loaded when actually needed
  • Uniform API: foreach works the same way for every collection
  • Composability: iterators can be decorated and combined

2. PHP Iterator interface and IteratorAggregate

PHP provides two ways to make objects traversable with foreach:


<?php

// Option 1: Iterator, the class implements Iterator directly
// 5 methods must be implemented
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, delegates to an iterator
// Cleaner when the class itself doesn't represent the iteration
class Collection implements \IteratorAggregate
{
    private array $items = [];

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

// Option 3: Generator-based (PHP 5.5+)
// Laziest approach, perfect for large datasets
function yieldProducts(array $ids): \Generator
{
    foreach ($ids as $id) {
        yield $id => loadProduct($id); // Lazy: loaded only when needed
    }
}
    

3. Varien_Data_Collection: the foundation

Magento\Framework\Data\Collection (formerly Varien_Data_Collection) is the base class of all Magento collections. It implements Iterator, Countable and ArrayAccess:


<?php

// vendor/magento/framework/Data/Collection.php (simplified)
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 and lazy loading

The DB-bound collection extends Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection and overrides load() to execute an SQL query, but only on first access:


<?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 is only executed here
     * Triggered by: 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;
    }
}
    

The critical thing to understand: the collection is a query builder, not an array. Only on the first foreach, count() or getItems() does the SQL query actually hit the database.


<?php

declare(strict_types=1);

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

// This line does NOT run any DB query!
$collection = $collectionFactory->create();

// These lines only configure the query builder:
$collection->addAttributeToSelect(['name', 'price', 'sku'])
    ->addAttributeToFilter('status', 1)
    ->addAttributeToFilter('visibility', ['in' => [3, 4]])
    ->setOrder('created_at', 'DESC')
    ->setPageSize(20)
    ->setCurPage(1);

// ONLY HERE does the SQL query actually execute:
foreach ($collection as $product) { // ← load() triggered here
    echo $product->getName();
}

// Second foreach: no second DB query, data is cached
foreach ($collection as $product) { // ← uses cached $_items
    echo $product->getSku();
}
    

5. Collection filters, joins and pagination

The AbstractCollection is a powerful query builder. The most important methods:


<?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: large catalogs without memory overflow

The most common performance problem: loading 100,000 products all at once. The solution is a chunked iterator that splits the collection into pages:


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

Usage inside an 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 as lazy iterators

PHP generators (since 5.5) are the most elegant way to implement lazy iterators:


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

What makes generators especially powerful is that they can communicate bidirectionally:


<?php

// Generator with send(), allows feedback from the 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. Building a custom iterator for Magento

Sometimes you need an iterator that isn't tied to a DB collection, for example for CSV files or external 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);
        }
    }
}
    

Usage:


<?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 comparison

Approach Memory Queries Best for
foreach ($collection as $item) All items in RAM 1 SQL <10,000 items, normal pages
getItems() All items in RAM 1 SQL When array access is needed
Chunked iterator (custom) Only 1 chunk in RAM N SQL (N = total/chunk) Batch jobs, imports/exports
Generator + SearchCriteria Only 1 page in RAM N SQL Service layer code
CSV iterator O(1), 1 line 0 SQL File-based import

A typical mistake when iterating over large collections:


<?php

// WRONG: loads ALL 500,000 products into RAM at once
$collection = $collectionFactory->create();
// No setPageSize() → PHP memory_limit exceeded

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

// CORRECT: chunk-based with setPageSize()
$collection = $collectionFactory->create();
$collection->setPageSize(1000);

// Or even better: ChunkedProductIterator (see above)
foreach ($chunkedIterator as $product) {
    processProduct($product);
}
    

10. Conclusion: using collections correctly

The Iterator Pattern is omnipresent in Magento, every collection is an iterator. The most important rules:

✓ Best practices

  • Always set setPageSize()
  • Select only the attributes you actually need (addAttributeToSelect)
  • For batch jobs: use a chunked iterator
  • Fully configure the collection before the forEach loop
  • Prefer generators for lazy pipelines

✗ Anti-patterns

  • No setPageSize() on large tables
  • addAttributeToSelect('*') inside loops
  • Loading a collection inside the constructor (too early)
  • Adding filters after load() has already run
  • Running a count query before and after changing filters

Summary

Pattern
The iterator enables sequential access to collections without exposing internal structure, every foreach in Magento relies on it
Lazy loading
AbstractCollection only runs SQL on the first foreach/count(), fully configure the query builder before iterating
Memory
For more than 10,000 items always use a chunked iterator or generator, this prevents OOM errors on large catalogs
Generators
PHP generators are the most elegant form of a lazy iterator, ideal for pipelines built on SearchCriteria or external data sources

Optimize collection performance

Analyze collections, fix memory problems or optimize batch imports.

????
Collection audit
Slow query analysis and N+1 problem detection in collections
Batch import
Memory efficient import of large product catalogs without OOM errors
????
Generator pipeline
Lazy loading pipelines for ETL processes and export services

Frequently asked questions about the Iterator Pattern in Magento

What is the Iterator Pattern in Magento? +
The Iterator Pattern enables sequential access to the elements of a collection without exposing its internal representation. All Magento collections implement the PHP Iterator interface, which makes foreach loops work transparently.
When does Magento execute the SQL query of a collection? +
Magento uses lazy loading: the SQL query only runs on first data access, that is the first foreach, count(), getItems() or getFirstItem() call. Configuring filters and sorting beforehand only adds conditions to the query builder.
How do I prevent memory overflow with large collections? +
Always set setPageSize() and, for large datasets, use a chunked iterator that loads the collection page by page. Call $collection->clear() and unset($collection) after each page to free RAM.
What is the difference between Varien_Data_Collection and AbstractCollection? +
Varien_Data_Collection is the base collection without a DB connection. AbstractCollection extends it with real lazy loading and SQL query execution via Zend_Db.
When should I use PHP generators instead of collections? +
Generators are a good fit when the data source isn't a Magento collection (CSV, API), when you want to build a lazy pipeline, or when memory usage should stay O(1).
Can I add more filters after the first foreach? +
No. Once the collection is loaded, further filters are ignored. To apply new filters, call clear(), set new filters and iterate again.
What does addAttributeToSelect('*') do in Magento? +
addAttributeToSelect('*') loads all EAV attributes and produces many JOIN operations, which leads to extremely slow queries. It's better to select only the attributes you need.
How does Magento implement the Iterator interface internally? +
Magento\Framework\Data\Collection implements IteratorAggregate and returns an ArrayIterator over the internal $_items array in getIterator(). This triggers load() if the collection isn't loaded yet.
Why is collection->count() sometimes slow? +
Calling count() before loading runs a separate COUNT(*) query. This is followed by the data query during the foreach, causing 2 DB queries instead of 1. Iterate the collection first, then call count().
How does a CSV iterator differ from a Magento collection? +
A CSV iterator reads line by line with a constant O(1) memory footprint. A Magento collection loads all results into a PHP array on first access. For huge imports, a custom iterator is significantly more memory efficient.