Slow Code
The four patterns that make custom Magento code slow: loading in a loop, network calls in blocks, around plugins and heavy observers.
$collection->addIdFilter($productId, $exclude = false)Most slow Magento code is slow for one of four reasons, and all four look harmless in review.
Loading in a loop
A collection created inside a foreach runs its queries once per row. Twenty rows means twenty loads. Gather the IDs first, load once with addIdFilter(), index the result by ID, and let the template read from the array.
A network call in a block
An HTTP request made from a block or a view model happens while the customer waits, and it happens again on every page view that is not cached. If the data must come from outside, store it with a lifetime and a cache tag, or move the fetch to cron and read what cron stored.
Around plugins
An around plugin wraps the method in a closure and keeps the remainder of the chain on the stack for the whole call. A before or an after plugin does not. Use around only when you need to skip the original call, or to change both its arguments and its result.
Heavy observers
An observer runs inside the request that fired the event. Work measured in seconds belongs in a message queue: the observer publishes a message, a consumer does the work.
Reading a value out of that array
$name = $badges[$id] ?? '';
$name = isset($badges[$id]) ? $badges[$id] : '';
Those two lines are the same thing. Any file changed here needs bin/magento cache:flush, and a new class or plugin needs bin/magento setup:di:compile before it loads in production mode.
Example
<?php
// app/code/Vendor/Perf/ViewModel/BadgeList.php
declare(strict_types=1);
namespace Vendor\Perf\ViewModel;
use Magento\Catalog\Model\Product;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;
use Magento\Framework\View\Element\Block\ArgumentInterface;
class BadgeList implements ArgumentInterface
{
public function __construct(
private readonly CollectionFactory $collectionFactory,
private readonly CacheInterface $cache,
private readonly SerializerInterface $serializer
) {
}
public function getBadges(array $productIds): array
{
sort($productIds);
$key = 'vendor_perf_badges_' . md5(implode(',', $productIds));
$cached = $this->cache->load($key);
if ($cached) {
return $this->serializer->unserialize($cached);
}
// One load for every id, instead of one load per id inside the template.
$collection = $this->collectionFactory->create();
$collection->addAttributeToSelect('name')->addIdFilter($productIds);
$badges = [];
foreach ($collection as $product) {
$badges[(int) $product->getId()] = (string) $product->getName();
}
$this->cache->save(
$this->serializer->serialize($badges),
$key,
[Product::CACHE_TAG],
3600
);
return $badges;
}
}When to use it
- Cut a category page from 300 queries to 30 without touching the theme.
- Keep a supplier stock lookup out of the product page request.
- Replace an inherited around plugin that broke a core discount.
- Move an order-export observer off the checkout request.
More examples
The slow version, so you recognise it
This is the shape to look for in review. It reads well, it passes a unit test with one product, and on a listing of twenty items it issues twenty collection loads. getFirstItem() on a freshly created collection is the tell: it means the method was written for one record and is being called in a loop. The fix is not a faster query, it is moving the load out of the loop. Note the escaper in the template; output from a view model is still untrusted.
<?php
// app/code/Vendor/Perf/ViewModel/BadgeList.php - do not ship this
declare(strict_types=1);
public function getBadge(int $productId): string
{
// A new collection, and a new set of queries, on every call.
$collection = $this->collectionFactory->create();
$collection->addAttributeToSelect('name')
->addIdFilter([$productId]);
return (string) $collection->getFirstItem()->getName();
}
// Called from the template once per row:
// <?php foreach ($block->getItems() as $item): ?>
// <?= $escaper->escapeHtml($viewModel->getBadge((int) $item->getProductId())) ?>
// <?php endforeach; ?>An after plugin where an around plugin is tempting
Everything this plugin needs is in the return value, so after is the right type. Written as aroundGetList it would need to accept a callable, call $proceed($searchCriteria), and keep the rest of the interception chain alive for the duration, for no benefit. Register it in etc/di.xml against Magento\Catalog\Api\ProductRepositoryInterface, then run bin/magento setup:di:compile and bin/magento cache:flush; a plugin added without recompiling in production mode simply never fires.
<?php
// app/code/Vendor/Perf/Plugin/LogSearchResults.php
declare(strict_types=1);
namespace Vendor\Perf\Plugin;
use Magento\Catalog\Api\Data\ProductSearchResultsInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Psr\Log\LoggerInterface;
class LogSearchResults
{
public function __construct(
private readonly LoggerInterface $logger
) {
}
public function afterGetList(
ProductRepositoryInterface $subject,
ProductSearchResultsInterface $result
): ProductSearchResultsInterface {
$this->logger->debug('product list size', ['count' => $result->getTotalCount()]);
return $result;
}
}An observer that publishes instead of working
The observer does one insert and returns, so the customer's place-order request is not waiting on an external system. The actual push to the ERP happens in a consumer, declared in etc/queue_consumer.xml and started with bin/magento queue:consumers:start vendor.order.sync. Publishing only the order ID rather than the order is deliberate: the consumer reloads it, so a message sitting in the queue for ten minutes still acts on current data. Reach for this whenever an observer calls anything over the network.
<?php
// app/code/Vendor/Perf/Observer/EnqueueOrderSync.php
declare(strict_types=1);
namespace Vendor\Perf\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\MessageQueue\PublisherInterface;
use Magento\Sales\Api\Data\OrderInterface;
class EnqueueOrderSync implements ObserverInterface
{
public function __construct(
private readonly PublisherInterface $publisher
) {
}
public function execute(Observer $observer): void
{
$order = $observer->getEvent()->getData('order');
if (!$order instanceof OrderInterface) {
return;
}
$this->publisher->publish('vendor.order.sync', (string) $order->getEntityId());
}
}
Discussion