Measuring First

Use the bundled profiler, the query log and the cache-debug header to get one repeatable number before changing any code.

Syntaxbin/magento dev:profiler:enable [html|csvfile]

Performance work starts with a number you can repeat. Magento ships three ways to get one, and none of them needs an external service.

The three tools

The profiler times the internal phases of a request. The query log records every SQL statement with the PHP call stack that produced it. The cache-debug header tells you whether the page came from the full page cache at all.

The profiler is a file flag. bin/magento dev:profiler:enable html writes var/profiler.flag and appends a timing table to the bottom of every HTML page. Pass csvfile instead and the rows go to a file, which is what you want for a page you cannot read on screen. bin/magento dev:profiler:disable removes the flag. No cache flush is needed; the flag is read per request.

The query log is a setting in app/etc/env.php: bin/magento dev:query-log:enable. Statements land in var/debug/db.log.

Measure the right page

In developer mode a storefront response carries X-Magento-Cache-Debug: HIT or MISS. A HIT measures the cache, not your code. Record both numbers for the same URL: the cold one and the warm one.

What trips people up

Developer-mode numbers are not production numbers. Developer mode regenerates static files and resolves dependency injection on demand. Compare developer against developer, or production against production, never one against the other.

Example

Example · php
<?php
// app/code/Vendor/Perf/Console/Command/TimeListCommand.php
declare(strict_types=1);

namespace Vendor\Perf\Console\Command;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class TimeListCommand extends Command
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
        private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
        ?string $name = null
    ) {
        parent::__construct($name);
    }

    protected function configure(): void
    {
        $this->setName('vendor:perf:time-list');
        $this->setDescription('Times one product list query');
        parent::configure();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $criteria = $this->searchCriteriaBuilder->setPageSize(100)->create();

        $start = microtime(true);
        $items = $this->productRepository->getList($criteria)->getItems();
        $ms = (microtime(true) - $start) * 1000;

        $output->writeln(sprintf('%d products in %.1f ms', count($items), $ms));

        return Command::SUCCESS;
    }
}

// bin/magento vendor:perf:time-list
// 100 products in 412.7 ms

When to use it

  • Prove a release made the category page faster rather than hoping it did.
  • Find which of a page's phases is slow before opening any class.
  • Count how many queries a custom block adds to the product page.
  • Check whether a slow page is actually missing the full page cache.

More examples

Timing one section with the framework profiler

Magento\Framework\Profiler is static on purpose, so it can be called from anywhere without being injected. Every call is ignored until the profiler is enabled, so leaving these lines in a module costs nothing in production. The named timer appears as its own row in the HTML table at the bottom of the page, nested under the phase that called it. Reach for this when the profiler shows a slow phase but not which of your classes is inside it.

Example · php
<?php
// app/code/Vendor/Perf/Model/ReportBuilder.php
declare(strict_types=1);

namespace Vendor\Perf\Model;

use Magento\Framework\Profiler;

class ReportBuilder
{
    public function build(array $rows): array
    {
        Profiler::start('vendor_perf_report');

        $result = [];
        foreach ($rows as $row) {
            $result[] = $this->summarise($row);
        }

        Profiler::stop('vendor_perf_report');

        return $result;
    }

    private function summarise(array $row): array
    {
        return ['sku' => $row['sku'], 'total' => array_sum($row['values'])];
    }
}

Registering the console command

A command class on disk does nothing until it is added to the commands array of Magento\Framework\Console\CommandList. After adding this file run bin/magento cache:flush, and bin/magento setup:di:compile as well if the site is in production mode, then bin/magento list shows the new command. If the command is missing from that list, this file is the first place to look.

Example · xml
<!-- app/code/Vendor/Perf/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\Console\CommandList">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="vendor_perf_time_list" xsi:type="object">
                    Vendor\Perf\Console\Command\TimeListCommand
                </item>
            </argument>
        </arguments>
    </type>
</config>

The constructor shorthand and its longhand

These two produce the same class. Magento's own core code mostly uses the longhand form because it predates PHP 8, so you will read both. Dependency injection does not care which you pick: the object manager reads the constructor's type declarations either way. Recognising the short form matters most when you open a core class and a third-party module in the same hour.

Example · php
<?php
// Two ways to write the same constructor.
declare(strict_types=1);

// Shorthand: promoted constructor properties, PHP 8.0 and later.
public function __construct(
    private readonly ProductRepositoryInterface $productRepository,
    ?string $name = null
) {
    parent::__construct($name);
}

// Longhand: the property and the assignment written out.
private ProductRepositoryInterface $productRepository;

public function __construct(
    ProductRepositoryInterface $productRepository,
    ?string $name = null
) {
    $this->productRepository = $productRepository;
    parent::__construct($name);
}

Discussion

  • Be the first to comment on this lesson.