Maggie 3.0Meet the newly updated AI tutor

See what's new
How to Use Multiple Collections in Magento 2

How to Use Multiple Collections in Magento 2

Learn how to properly use multiple collection instances in Magento 2 including creating new collections with factories, clearing & resetting, and PHP's clone keyword.

Call create() on the collection factory once per query. Filter methods like addAttributeToFilter() return the same object, so reusing one collection stacks every filter into a single query. If you need a shared base query, build it once and clone it: Magento's AbstractDb::__clone() copies the select, so each branch filters independently.

Reusing one collection for two queries looks harmless. It isn't, and the reason is a single return $this;.

What's a collection factory in Magento?

A collection factory (like Magento\Catalog\Model\ResourceModel\Product\CollectionFactory) is a class Magento generates for you. Its create() method asks the object manager for a new collection instance. That's all it does.

The collection is the thing that holds the query. You add filters to it, and when you iterate over it, it builds the SQL, runs it and hands you the items:

<?php
 
declare(strict_types=1);
 
namespace MyVendor\MyModule\Model;
 
use Magento\Catalog\Model\Product\Attribute\Source\Status;
use Magento\Catalog\Model\Product\Visibility;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory as ProductCollectionFactory;
 
class SomeCustomModel
{
    public function __construct(
        private readonly ProductCollectionFactory $productCollectionFactory,
    ) {
    }
 
    public function getProductIds(): array
    {
        $ids = [];
        $productCollection = $this->productCollectionFactory->create();
        $productCollection
            ->addAttributeToFilter('status', ['eq' => Status::STATUS_ENABLED])
            ->addAttributeToFilter('visibility', ['in' => [
                Visibility::VISIBILITY_IN_CATALOG,
                Visibility::VISIBILITY_IN_SEARCH,
                Visibility::VISIBILITY_BOTH,
            ]]);
 
        foreach ($productCollection as $product) {
            $ids[] = $product->getEntityId();
        }
 
        return $ids;
    }
}

This retrieves product IDs, but only for products that are enabled and have a public visibility. Note the magic getter getEntityId() -- understanding when to use getData() vs. magic getters in Magento 2 is an important best practice for data access.

That chained call is the clue to everything that follows. addAttributeToFilter() doesn't return a new, filtered collection. It adds a WHERE condition to this collection and returns $this.

Why does reusing a collection return no results?

Let's modify the example. Now we want all simple products and all configurable products, each in their own array:

<?php
 
declare(strict_types=1);
 
namespace MyVendor\MyModule\Model;
 
use Magento\Catalog\Model\Product\Type;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory as ProductCollectionFactory;
use Magento\ConfigurableProduct\Model\Product\Type\Configurable;
 
class SomeCustomModel
{
    public function __construct(
        private readonly ProductCollectionFactory $productCollectionFactory,
    ) {
    }
 
    public function getProductIds(): array
    {
        $simpleProductIds = [];
        $configurableProductIds = [];
        $productCollection = $this->productCollectionFactory->create();
        $simpleProducts = $productCollection->addAttributeToFilter('type_id', ['eq' => Type::TYPE_SIMPLE]);
        $configurableProducts = $productCollection->addAttributeToFilter('type_id', ['eq' => Configurable::TYPE_CODE]);
 
        foreach ($simpleProducts as $product) {
            $simpleProductIds[] = $product->getEntityId();
        }
 
        foreach ($configurableProducts as $product) {
            $configurableProductIds[] = $product->getEntityId();
        }
 
        return [
            'simple' => $simpleProductIds,
            'configurable' => $configurableProductIds,
        ];
    }
}

Looks correct, right? Not so fast.

$simpleProducts, $configurableProducts and $productCollection aren't three collections. They're three variables pointing at the same object. Both filters land on one query, and they get ANDed together:

WHERE (e.type_id = 'simple') AND (e.type_id = 'configurable')

No product is both, so both loops come back empty. (And since a collection only loads once, the second loop just reuses the first loop's empty result anyway.)

Nothing "forgot to reset itself". The collection did exactly what it was told. There are three ways to get two independent queries.

Solution 1: Create a new instance of the collection

Call create() once per query. Each call returns a brand-new collection with its own select, so filters on one never touch the other:

$simpleProductCollection = $this->productCollectionFactory->create();
$configurableProductCollection = $this->productCollectionFactory->create();
 
$simpleProducts = $simpleProductCollection->addAttributeToFilter('type_id', ['eq' => Type::TYPE_SIMPLE]);
$configurableProducts = $configurableProductCollection->addAttributeToFilter('type_id', ['eq' => Configurable::TYPE_CODE]);

This is the most straightforward solution and the one I'd reach for by default. Anyone reading the code can see there are two queries.

Solution 2: Clear and reset the collection

You can also reuse one instance, but it takes two steps, because each one only undoes half of the state.

clear() resets the loaded state: it empties the items, resets the total record count and marks the collection as not loaded (Magento\Framework\Data\Collection::clear()). It does not touch the filters. The WHERE clause lives on the select object, so you reset that separately with getSelect()->reset(), passing the Magento\Framework\DB\Select::WHERE part:

$productCollection = $this->productCollectionFactory->create();
 
$productCollection->addAttributeToFilter('type_id', ['eq' => Type::TYPE_SIMPLE]);
foreach ($productCollection as $product) {
    $simpleProductIds[] = $product->getEntityId();
}
 
// First step: forget the loaded items
$productCollection->clear();
 
// Second step: drop the WHERE conditions from the query
$productCollection->getSelect()->reset(\Magento\Framework\DB\Select::WHERE);
 
$productCollection->addAttributeToFilter('type_id', ['eq' => Configurable::TYPE_CODE]);
foreach ($productCollection as $product) {
    $configurableProductIds[] = $product->getEntityId();
}

Notice you have to read the simple products out before resetting. It's still one object, so once you reset it, there's no simple-product result left to go back to.

A couple of caveats. Skip either step and you get the wrong result, so this one is easy to break in a later refactor. And reset(WHERE) only removes WHERE conditions: when you filter on a non-static EAV attribute (like name or status), addAttributeToFilter() also adds a JOIN to the attribute's value table, and that join stays behind. This works cleanly for static columns like type_id, and gets messy fast for anything else.

Solution 3: Use PHP's clone keyword

The third option is used throughout Magento core: clone the collection.

On its own, PHP's clone is a shallow copy. The new collection would point at the same select object as the original, and you'd be right back to sharing one query. It works here because Magento\Framework\Data\Collection\AbstractDb::__clone() explicitly clones the select too:

vendor/magento/framework/Data/Collection/AbstractDb.php
public function __clone()
{
    if (is_object($this->_select)) {
        $this->_select = clone $this->_select;
    }
}

So each clone gets its own copy of the query as it stood at the moment of cloning, and from there they filter independently:

$productCollection = $this->productCollectionFactory->create();
 
// Clone BEFORE loading or filtering what should differ
$secondProductCollection = clone $productCollection;
 
$simpleProducts = $productCollection->addAttributeToFilter('type_id', ['eq' => Type::TYPE_SIMPLE]);
$configurableProducts = $secondProductCollection->addAttributeToFilter('type_id', ['eq' => Configurable::TYPE_CODE]);

Clone before the collection loads. Clone a loaded collection and the copy comes with the loaded items and the "already loaded" flag, so new filters on it never run.

Where clone really shines is a shared base query. Add the filters both queries have in common, clone, then branch. That's what core does in getSuggestedCategoriesJson() of Magento\Catalog\Block\Adminhtml\Category\Tree:

Magento\Catalog\Block\Adminhtml\Category\Tree
...
public function getSuggestedCategoriesJson($namePart)
{
    $storeId = $this->getRequest()->getParam('store', $this->_getDefaultStoreId());
 
    /* @var $collection Collection */
    $collection = $this->_categoryFactory->create()->getCollection();
 
    $matchingNamesCollection = clone $collection;
    $escapedNamePart = $this->_resourceHelper->addLikeEscape(
        $namePart,
        ['position' => 'any']
    );
    $matchingNamesCollection->addAttributeToFilter(
        'name',
        ['like' => $escapedNamePart]
    )->addAttributeToFilter(
        'entity_id',
        ['neq' => Category::TREE_ROOT_ID]
    )->addAttributeToSelect(
        'path'
    )->setStoreId(
        $storeId
    );
 
    $shownCategoriesIds = [];
    foreach ($matchingNamesCollection as $category) {
        foreach (explode('/', $category->getPath() ?: '') as $parentId) {
            $shownCategoriesIds[$parentId] = 1;
        }
    }
 
    $collection->addAttributeToFilter(
        'entity_id',
        ['in' => array_keys($shownCategoriesIds)]
    )->addAttributeToSelect(
        ['name', 'is_active', 'parent_id']
    )->setStoreId(
        $storeId
    );
 
    $categoryById = [
        Category::TREE_ROOT_ID => [
            'id' => Category::TREE_ROOT_ID,
            'children' => [],
        ],
    ];
    foreach ($collection as $category) {
        foreach ([$category->getId(), $category->getParentId()] as $categoryId) {
            if (!isset($categoryById[$categoryId])) {
                $categoryById[$categoryId] = ['id' => $categoryId, 'children' => []];
            }
        }
        $categoryById[$category->getId()]['is_active'] = $category->getIsActive();
        $categoryById[$category->getId()]['label'] = $category->getName();
        $categoryById[$category->getParentId()]['children'][] = & $categoryById[$category->getId()];
    }
 
    return $this->_jsonEncoder->encode($categoryById[Category::TREE_ROOT_ID]['children']);
}
...

The category collection is cloned into $matchingNamesCollection, which gets a name filter to find matching categories. The original $collection is then filtered down to those categories and their parents. Two queries, one starting point.

create() twice would have worked here too. The difference isn't performance (the factory is a shared instance either way, and either cost is nothing next to the SQL query). It's that clone lets both queries start from whatever was already set up on the original.

Which one should you use?

Default to a second create() call. Reach for clone when you need a base query you can branch from. Keep clear-and-reset for the rare case where you really do need to reuse one instance, and only on static columns.

If your collection is returning fewer products than you expect (rather than none at all), these troubleshooting steps for product collections walk through printing the SQL and finding the filter you didn't add. And if you want to go deeper on models, resource models and collections, that's what the Magento 2 Coding Kickstart course is for.

Something here out of date?

Magento moves, and so does this site. If a step no longer works, tell me and I'll fix it.

Get the next one by email

Every other week I send a short newsletter on how PHP + Magento actually work: releases, security alerts, and one thing explained under the hood. Enroll in the Library and it's yours. No credit card needed.

The University includes every PHP + Magento course available, and every new M.academy course as it ships.