Categories
The category tree, the anchor setting, how products are attached with a position, and the index that makes any of it visible.
CategoryLinkManagementInterface::assignProductToCategories($productSku, array $categoryIds): boolCategories form a tree stored in catalog_category_entity. Each row keeps parent_id, level, position and a path such as 1/2/13, which is the ids of every ancestor joined by slashes. Node 1 is the invisible tree root. Each store group names one of its children as its root category, which is why "Default Category", usually id 2, is what a storefront actually shows.
Anchor
The is_anchor attribute decides whether a category lists the products of its descendants as well as its own. Turn it on for Men and the Men page shows everything under Shirts and Trousers. Leave it off and Men lists only products assigned to Men directly.
Products and position
Assignment lives in catalog_category_product: category id, product id, position. Position is per category, so one product can be first in one category and last in another. CategoryLinkManagementInterface::assignProductToCategories() replaces the product's entire category list rather than adding to it, so read the current list first if you mean to append.
Nothing shows until the index runs
The storefront reads the catalog_category_product index tables, not the assignment table. After any change run bin/magento indexer:reindex catalog_category_product and bin/magento cache:flush. With the indexer set to Update by Schedule and cron stopped, assignments look as though they did nothing at all.
Category Permissions, which hides categories and prices from chosen customer groups, is Adobe Commerce only. Open Source has no equivalent.
Example
<?php
// app/code/Vendor/Catalog/Setup/Patch/Data/AddClearanceCategory.php
declare(strict_types=1);
namespace Vendor\Catalog\Setup\Patch\Data;
use Magento\Catalog\Api\CategoryRepositoryInterface;
use Magento\Catalog\Api\Data\CategoryInterfaceFactory;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Store\Model\StoreManagerInterface;
class AddClearanceCategory implements DataPatchInterface
{
public function __construct(
private readonly CategoryInterfaceFactory $categoryFactory,
private readonly CategoryRepositoryInterface $categoryRepository,
private readonly StoreManagerInterface $storeManager
) {
}
public function apply(): self
{
$defaultStore = $this->storeManager->getDefaultStoreView();
if ($defaultStore === null) {
return $this;
}
$category = $this->categoryFactory->create();
$category->setName('Clearance');
$category->setParentId((int)$defaultStore->getRootCategoryId());
$category->setIsActive(true);
$category->setCustomAttribute('url_key', 'clearance');
$category->setCustomAttribute('is_anchor', 1);
$this->categoryRepository->save($category);
return $this;
}
public static function getDependencies(): array
{
return [];
}
public function getAliases(): array
{
return [];
}
}
// Applied by bin/magento setup:upgrade. The repository builds path and level
// from parent_id, so neither is set here. Then:
// bin/magento indexer:reindex catalog_category_product
// bin/magento cache:flush
// The category then appears in the top menu of the default store view.When to use it
- Create a Clearance category during deployment so it exists identically in every environment.
- Work out why a product assigned in the admin still does not appear on its category page.
- Decide whether a parent category should be an anchor so it lists everything beneath it.
- Add a product to one extra category from an integration without wiping its other categories.
More examples
Appending categories instead of replacing them
Use this when an integration adds a product to a promotional category and must not disturb the merchandiser's existing assignments. CategoryLinkManagementInterface::assignProductToCategories() takes the full list and is right for an import that owns the whole catalog; it is wrong for an incremental update, where it silently removes every category you did not pass. Position is set on the link, which is where per-category sort order lives.
<?php
// app/code/Vendor/Catalog/Model/CategoryAppender.php
declare(strict_types=1);
namespace Vendor\Catalog\Model;
use Magento\Catalog\Api\CategoryLinkManagementInterface;
use Magento\Catalog\Api\CategoryLinkRepositoryInterface;
use Magento\Catalog\Api\Data\CategoryProductLinkInterfaceFactory;
class CategoryAppender
{
public function __construct(
private readonly CategoryLinkRepositoryInterface $categoryLinkRepository,
private readonly CategoryProductLinkInterfaceFactory $linkFactory
) {
}
public function append(string $sku, int $categoryId, int $position): void
{
// One link at a time, so existing assignments are untouched.
// assignProductToCategories() would replace the whole list.
$link = $this->linkFactory->create();
$link->setSku($sku);
$link->setCategoryId((string)$categoryId);
$link->setPosition($position);
$this->categoryLinkRepository->save($link);
}
}Reading the tree down to a given depth
getTree() returns a CategoryTreeInterface whose getChildrenData() holds the next level down. The depth argument is what keeps this cheap: without it, a large catalog loads the entire tree into memory. Reach for this to build a navigation fragment, a report filter, or an admin dropdown. Note that the root category id comes from the store, not from a constant, because each store group has its own root.
<?php
// app/code/Vendor/Catalog/Model/TopLevelCategories.php
declare(strict_types=1);
namespace Vendor\Catalog\Model;
use Magento\Catalog\Api\CategoryManagementInterface;
use Magento\Store\Model\StoreManagerInterface;
class TopLevelCategories
{
public function __construct(
private readonly CategoryManagementInterface $categoryManagement,
private readonly StoreManagerInterface $storeManager
) {
}
/**
* @return array<int, string> category id => name
*/
public function forCurrentStore(): array
{
$rootId = (int)$this->storeManager->getStore()->getRootCategoryId();
$tree = $this->categoryManagement->getTree($rootId, 1);
$names = [];
foreach ($tree->getChildrenData() as $child) {
$names[(int)$child->getId()] = (string)$child->getName();
}
return $names;
}
}Finding a category by its URL key
Scripts and migrations often know a category by its URL key rather than its id, because ids differ between environments. CategoryListInterface is the searchable counterpart to CategoryRepositoryInterface, which can only fetch by id. Passing the value through SearchCriteriaBuilder::addFilter() means it is bound by the adapter; building the same condition by string concatenation is the standard route to an injection bug.
<?php
// app/code/Vendor/Catalog/Model/CategoryFinder.php
declare(strict_types=1);
namespace Vendor\Catalog\Model;
use Magento\Catalog\Api\CategoryListInterface;
use Magento\Catalog\Api\Data\CategoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
class CategoryFinder
{
public function __construct(
private readonly CategoryListInterface $categoryList,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder
) {
}
public function byUrlKey(string $urlKey): ?CategoryInterface
{
// The value is passed as a bound filter, never concatenated into SQL.
$criteria = $this->searchCriteriaBuilder
->addFilter('url_key', $urlKey)
->setPageSize(1)
->create();
$items = $this->categoryList->getList($criteria)->getItems();
return $items === [] ? null : array_shift($items);
}
}
Discussion