Table of Contents
- The PHP iterator interface: the basics
- AbstractCollection in Magento 2: lazy loading
- EAV vs. flat: addAttributeToFilter vs. addFieldToFilter
- Filtering, sorting and paginating collections
- Building a custom collection
- SearchCriteria and SearchResults as a modern alternative
- Debugging collection queries
- Performance tips for collections
- Summary
- FAQ
The PHP iterator interface: the basics
The iterator pattern is one of the classic design patterns from the Gang of Four book and describes a standardized interface for iterating over objects in a collection, regardless of how that collection is organized internally. The caller does not need to know whether the data is stored in an array, a linked list, a database result set or any other data structure. It simply calls next(), current() and valid() and receives the elements one after another.
PHP implements the iterator pattern through two interfaces in the standard library: Iterator and IteratorAggregate. The Iterator interface defines five methods: current(), key(), next(), rewind() and valid(). The IteratorAggregate interface is simpler and defines only getIterator(), which returns a Traversable object. Both interfaces allow the use of the foreach language construct.
PHP 8.4 brings important improvements around type safety for iterators. Generic-style PHPDoc annotations (@extends \IteratorAggregate<int, PostInterface>) are understood by PHPStan and Psalm and enable type-safe foreach loops. When a collection iterates over PostInterface objects, PHPStan knows that $post inside the foreach loop is of type PostInterface, and can perform the corresponding type checks. That is a substantial improvement over the untyped mixed of the older Magento architecture.
The Traversable interface is the parent interface in PHP that unifies both Iterator and IteratorAggregate. All native PHP functions that work with iterables, such as iterator_to_array(), iterator_count() and many SPL functions, accept Traversable objects. Magento 2 collections are Traversable and can be used with all of these functions.
AbstractCollection in Magento 2: lazy loading
Magento 2 collections extend Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection. This class is the heart of the collection system and implements IteratorAggregate. That means every Magento 2 collection is a Traversable object and can be iterated over with foreach. The getIterator() method triggers the loading of the data, which is lazy loading in its purest form.
Lazy loading means: the collection initially only configures the SQL query (filters, sorting, pagination). The actual database query is only executed once the data is actually accessed. As long as you only add filters and never read anything, no SQL runs at all. That is efficient, because unnecessary database queries are avoided. The isLoaded state is internally initialized to false and set to true after the first load.
<?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();
}
}
An important detail: if you iterate over a collection with foreach and then call getItems() again, the SQL query is not executed again, the data is already in memory. That is efficient, but it can lead to confusion if you add more filters after the first load. In that case, the new filters would not affect the already loaded data. You then need to call clear() to reset the loaded state.
EAV vs. flat: addAttributeToFilter vs. addFieldToFilter
One of the most common sources of errors with Magento 2 collections is confusing the two filter methods addAttributeToFilter() and addFieldToFilter(). These methods look similar but work in fundamentally different ways and are used for different table types.
addAttributeToFilter() is for EAV collections (Entity-Attribute-Value). In the EAV system, attributes are not stored as direct columns in the main table but in separate value tables (one per data type: catalog_product_entity_varchar, catalog_product_entity_decimal, and so on). A filter on an EAV attribute therefore requires a JOIN on the corresponding value table. addAttributeToFilter() handles this JOIN automatically and adds the WHERE criterion for the right attribute code. Examples: all catalog collections (products, categories) and customer collections.
addFieldToFilter() is for flat collections, meaning tables where every property is a direct column in the main table. Sales entities (orders, invoices, shipments, credit memos), quotes and all custom modules with plain MySQL tables use flat collections. A filter with addFieldToFilter() adds a WHERE criterion directly to the main table without any JOINs. That generally makes flat collections faster than EAV collections.
What happens if you use the wrong method? addFieldToFilter() on an EAV collection only works if the field really is a direct column of the main table (for example entity_id, sku, created_at). For EAV attributes you must use addAttributeToFilter(), otherwise you get SQL errors or incorrect results. Custom collections (for your own modules) almost always use addFieldToFilter(), because custom tables are usually flat.
Filtering, sorting and paginating collections
The Magento 2 collection API offers a fluent interface for all common database operations. Filters are passed as condition arrays that follow the Zend_Db_Select format. The most common conditions are: 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 for date ranges.
<?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 a 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(),
];
}
}
The getSize() method is especially important for performant pagination. It runs a separate SELECT COUNT(*) without loading the actual data. That makes it possible to determine the total number of results for a pagination display without loading every record into memory. Combined with setPageSize() and setCurPage(), getSize() forms the foundation for efficient pagination in Magento 2.4.8.
Building a custom collection
For a custom Magento 2 module you need to create your own collection class that extends AbstractCollection. This class wires up the connection between model and resource model and can offer its own filter methods as a fluent API. The convention for the file location is 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 the 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;
}
}
The collection can then be used in a very readable way, because the custom methods form a domain-specific language. Instead of cryptic filter arrays, you write $collection->addActiveFilter()->addPublishedFilter()->addStoreFilter(1). This fluent API is an important aspect of good Magento 2 architecture: collection logic belongs inside the collection, not in the calling code.
SearchCriteria and SearchResults as a modern alternative
While direct collections have their place in internal code, the Magento 2 service contract system relies on SearchCriteria as an abstract query language. SearchCriteria is a value object that encapsulates filters, sorting and pagination without being tied to a concrete collection or database table. It is the language in which repository clients communicate with repositories.
The advantage of SearchCriteria is full testability. You can create a SearchCriteria object, pass it to a mocked repository, and the mocked repository returns SearchResults without a single database access. By contrast, testing a collection directly requires a database access or complex mocking of the resource model.
SearchResults is the corresponding result container interface: getItems() returns the loaded entities, getTotalCount() returns the total count (for pagination). SearchResults is a pure value object with no knowledge of SQL. The repository is responsible for translating SearchCriteria into collection operations and wrapping the results in SearchResults.
Using SearchCriteria is especially relevant for REST API endpoints in Magento 2: when a repository interface with getList(SearchCriteriaInterface $criteria) is called via the REST API, Magento automatically converts the URL query parameters into a SearchCriteria object. That means a correctly implemented repository is automatically reachable through the REST API, without any extra code.
Debugging collection queries
Debugging collection queries is an essential skill for Magento 2 developers. The most direct route is (string) $collection->getSelect(), which returns the full SQL statement as a string. You can then paste that string into a database tool such as MySQL Workbench or TablePlus and analyze it. It shows the WHERE clauses as well as all JOINs, ORDER BY and LIMIT statements.
$collection->printLogQuery(true) outputs the query directly into the PHP output. That is useful for quick development work but should never be left in production code. A cleaner alternative for development is writing the query string to the Magento log: $this->logger->debug('Collection SQL: ' . $collection->getSelect()).
In the development environment, bin/magento dev:query-log:enable can log every database query to var/log/db.log. That gives you a complete overview of all SQL queries on a page, including their execution time. This is particularly useful for spotting N+1 query problems: if you see 50 near-identical queries, you most likely have a collection making individual load() calls inside a loop.
Performance tips for collections
The most important performance tip for collections: always load only the fields you need. For EAV collections use addAttributeToSelect(), and for flat collections use an explicit getSelect()->columns([...]). A SELECT * on a catalog product in Magento 2 can trigger dozens of EAV JOINs, which is expensive. If you only need a product's title and URL, you should only load those two attributes.
For pagination the rule is: always call getSize() before loading the data if you need the total count. getSize() only runs a SELECT COUNT(*) without loading the entities. If you call getSize() after getItems() or a foreach, the data is already in memory, so the COUNT is then calculated from the loaded data, which is correct but might not be what you want if you are trying to optimize separate pagination and data loading steps.
Collections that iterate over large amounts of data for exports or batch processes should not load all the data into memory at once. The recommended strategy is page-by-page loading: iterate over all pages, load only one page at a time with setPageSize() and setCurPage(), process the data and free the memory. In Magento 2.4.8 there are also walk methods and stream-based approaches available through custom resource model methods.
Database indexes are critical for collection performance. If you frequently filter on a particular field (for example is_active, store_id, publish_date), that field should carry an INDEX in the db_schema.xml. Without an index, MySQL runs a full table scan, which becomes exponentially slower as the record count grows. Declarative schema in Magento 2.4.8 makes it easy to declare indexes correctly and manage them through patches.
Summary: iterator & collections in Magento 2
The iterator pattern runs deep through Magento 2 collections. AbstractCollection implements IteratorAggregate and loads data lazily. EAV collections use addAttributeToFilter() with automatic JOINs, flat collections use addFieldToFilter() with a direct WHERE. getSize() gives you a COUNT without loading data. SearchCriteria is for service contracts. Custom collections with a fluent API cover domain-specific filters.
Lazy Loading
SQL only on first access. Triggers: foreach, getItems(), getSize(), count(). Cached afterward. clear() resets the state.
EAV vs. Flat
addAttributeToFilter() for Catalog/Customer. addFieldToFilter() for Sales/custom modules. Wrong method means SQL errors or wrong results.
SearchCriteria
For repository interfaces, REST API, testable code. SearchCriteriaBuilder plus SortOrderBuilder. SearchResults with getItems() and getTotalCount().
Pagination
setPageSize() + setCurPage(). getSize() for a COUNT without loading data. getLastPageNumber() for the total page count.
Mironsoft
Building custom collections and repositories?
We build performant Magento 2 modules with correct collection design, SearchCriteria integration and a complete repository pattern. Clean, testable, upgrade-safe.
Custom Collections
Custom collections with a fluent filter API and store scope support
Repository Pattern
Service contracts with SearchCriteria, SearchResults and an interface-first approach
Performance
Query optimization, index analysis and lazy-loading strategies