While I was building a Quick View module - a button on each category page product-card that brings up a modal of the product - I ran into a challenge: How does the button get the product it needs to describe?
The button needs the product. At minimum the name, for an aria-label like "Quick view: Cauldron". In Hyva's item.phtml the product is right there in a local $product variable, so my assumption is that it's available in my button too. It isn't! Local template variables don't cross block boundaries. A child block's template only gets $block. So where do I actually get this product? Where does it come from?
Two common approaches I ended up skipping
I came across two solutions to "add something to every product card":
- Override the whole
item.phtml. This was too heavy. I didn't want to inherit the responsibility of a large core template forever. - Inject the button with JavaScript after render. This works, but your feature is now glued to the DOM and at the mercy of shifts in markup.
Magento actually has something intentional that avoids both. There's a container block you can drop child blocks into via layout XML.
The addto container
Each product card includes an empty block named catalog.list.item.addto, of type Magento\Catalog\Block\Product\ProductList\Item\Container. It has no template; its only job is to hold per-product action blocks.
You add to it from your own module without touching any core file:
<referenceBlock name="catalog.list.item.addto">
<block class="Magento\Framework\View\Element\Template"
name="quickview.button"
template="Jack_QuickView::Button.phtml"/>
</referenceBlock>
referenceBlock means "find the existing block by name and add this child to it." Now quickview.button is a child of the container.
The block tree (NOT the template nesting)
Here's the structure. Unintuitively, the block tree is not the same as the visual template nesting.
category.products.list -> template: product/list.phtml (the loop)
product_list_item -> template: product/list/item.phtml ($product lives here)
catalog.list.item.addto -> Container, NO template (your button's PARENT)
quickview.button -> template: Button.phtml (YOUR block)
The product_list_item block uses Magento's Magento\Catalog\Block\Product\View class, and its template is item.phtml. Note here that item.phtml is not your parent. Your direct parent is the addto Container. item.phtml belongs to your grandparent block. The gap there is the whole point of this blog post.
The product travels in 3 hops
The product is carried down the tree one step at a time.
Hop 1: list -> item. Hyva's list.phtml loads the collection and renders each card, handing the current product into item.phtml, where it becomes the local $product.
Hop 2: item -> container. item.phtml pushes the product onto the container:
<?php if ($addToBlock = $block->getChildBlock('addto')): ?>
<?= $addToBlock->setProduct($product)->getChildHtml() ?>
<?php endif; ?>
After this line, the product lives on the Container block.
Hop 3: container -> children. When the Container renders, it loops its children and calls setProduct(). It only does this on children that implement Magento\Catalog\Block\Product\AwareInterface. Plain blocks are skipped:
foreach ($layout->getChildBlocks($name) as $child) {
if ($child instanceof ProductAwareInterface) {
$child->setProduct($this->getProduct());
}
}
My button is a plain Magento\Framework\View\Element\Template, so it fails that instanceof check. The product never lands on it. This means that the $block->getProduct() comes back empty, and nothing tells you why.
(That's the behavior in the Magento version I'm on. If you're on a different version, open your own copy of Container.php and confirm. Some versions may call setProduct() on every child rather than guarding on the interface.)
Pull from the parent (the fix)
Instead of depending on whether the container pushes to my block, I pull from the parent, which always has the product. This is because item.phtml set it there, in Hop 2:
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Catalog\Block\Product\ProductList\Item\Container|null $parent */
$parent = $block->getParentBlock();
$product = $parent?->getProduct();
if (!$product) {
return;
}
$productName = $product->getName();
One detail: getParentBlock() is typed to return the generic BlockInterface, which has no getProduct(). You can annotate $parent as the real runtime type, the Container. This documents what's actually there and keeps the IDE honest.
This pull works across different versions. whether or not the container propagates to children, the product is on the container itself, so reaching up always works.
The lesson
The lesson here is the model:
- Block tree != template nesting. Your parent is the block that contains yours in layout, which may not be the template that visually wraps it.
- Data is hand-carried instead of inherited. A child block will not automatically see its parent's data. It's pushed by
setProductor pulled bygetParentBlockat each step. - Read core internals. I could have shipped a bug assuming how the Container behaves. To get the answer for your install: read
Container.php, or dropvar_dump($block->getParentBlock()->getProduct());at the top of your template and load a category page. (Take note of thegetParentBlock().var_dumping$block->getProduct()directly would print null here, since in Hyva the product lives on the parent instead of the button.)