hryvinskyi / magento2-base
N/A
Magento 2 Base Module
A comprehensive Magento 2 base module providing:
- Injected services: Role interfaces for array manipulation, HTML generation, JSON handling, and debugging, ported from Yii2
- ViewModel Registry: Global access to view models in templates without layout XML configuration
- Layout Debugging: Developer mode tools for layout XML and block structure inspection
Installation Guide
composer require hryvinskyi/magento2-base
bin/magento module:enable Hryvinskyi_Base
bin/magento setup:upgrade
Table of Contents
Core Features
1. ViewModel Registry ($viewModels)
Access any view model directly in templates without declaring them in layout XML. The $viewModels variable is automatically available in all .phtml templates.
Usage in templates:
<?php
/** @var Magento\Framework\View\Element\Template $block */
/** @var Hryvinskyi\Base\Model\ViewModelRegistry $viewModels */
// Get any view model by class name
$productViewModel = $viewModels->require(\Vendor\Module\ViewModel\Product::class);
$categoryViewModel = $viewModels->require(\Vendor\Module\ViewModel\Category::class);
// For ESI blocks with TTL, pass the block reference for proper cache handling
$esiViewModel = $viewModels->require(\Vendor\Module\ViewModel\Esi::class, $block);
// Use the view model
echo $productViewModel->getProductName();
?>
How it works:
- Automatically injected into all PHP template engines via
di.xml - Collects cache tags from view models implementing
IdentityInterface - Properly handles Varnish ESI blocks to prevent incorrect cache purging
- Validates that requested classes implement
ArgumentInterface
Cache handling for ESI blocks:
// Main page - cache tags collected normally
$viewModel = $viewModels->require(MyViewModel::class);
// ESI block (ttl="300") - pass $block to prevent main page cache pollution
$viewModel = $viewModels->require(MyViewModel::class, $block);
2. Layout Debugging (Developer Mode Only)
Automatic layout debugging tools enabled when Magento is in developer mode.
Features:
- Visual block hierarchy display
- Layout XML structure viewer
- Block rendering time tracking
- Cache status indicators
Automatically enabled when:
bin/magento deploy:mode:set developer
Configuration:
The debugging information is controlled by Hryvinskyi\Base\Api\LayoutDebugConfigInterface:
- Only works in developer mode (
State::MODE_DEVELOPER), and there only for a visitor the developer restriction
(dev/restrict/allow_ips) allows; with no list set, every visitor - Injected into responses via event observers
- No configuration needed - works out of the box
What you get:
- Block nesting and parent-child relationships
- Template file locations with full paths
- Block class names and cache keys
- Layout handle processing order
- Visual hierarchy with indentation
3. Extensible Admin Menu
A reusable dropdown menu component for Magento admin pages that can be configured entirely via layout XML. Perfect for creating navigation menus in custom admin modules.
Features:
- Fully configurable via layout XML (no PHP code required)
- Route-based URL generation with parameters support
- Sortable menu items with sort_order
- Custom CSS classes and icons per item
- Per-item ACL resource: items the current admin user may not open are hidden
- Optional titled groups shown side by side, with long groups split into columns
- Translatable labels
- Reusable across multiple modules
Basic Usage in Layout XML:
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<css src="Hryvinskyi_Base::css/styles.css"/>
</head>
<body>
<referenceBlock name="page.title">
<block class="Hryvinskyi\Base\Block\Adminhtml\Menu" name="my.module.menu">
<arguments>
<argument name="menu_title" xsi:type="string" translate="true">My Module</argument>
<argument name="items" xsi:type="array">
<item name="dashboard" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Dashboard</item>
<item name="route" xsi:type="string">mymodule/dashboard/index</item>
<item name="sort_order" xsi:type="number">10</item>
</item>
<item name="settings" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Settings</item>
<item name="route" xsi:type="string">adminhtml/system_config/edit</item>
<item name="route_params" xsi:type="array">
<item name="section" xsi:type="string">mymodule</item>
</item>
<item name="sort_order" xsi:type="number">20</item>
</item>
</argument>
</arguments>
</block>
</referenceBlock>
</body>
</page>
Creating a Reusable Menu Handle:
Create a shared layout handle file (e.g., mymodule_menu.xml) to avoid duplication:
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<css src="Hryvinskyi_Base::css/styles.css"/>
</head>
<body>
<referenceBlock name="page.title">
<block class="Hryvinskyi\Base\Block\Adminhtml\Menu" name="mymodule.menu">
<arguments>
<argument name="menu_title" xsi:type="string" translate="true">My Module</argument>
<argument name="items" xsi:type="array">
<!-- Define your menu items here -->
</argument>
</arguments>
</block>
</referenceBlock>
</body>
</page>
Then include it in your page layouts:
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<update handle="mymodule_menu"/>
<body>
<referenceContainer name="content">
<uiComponent name="mymodule_listing"/>
</referenceContainer>
</body>
</page>
Menu Item Configuration Options:
| Option | Type | Required | Description |
|---|---|---|---|
label |
string | Yes | Menu item text (supports translation with translate="true") |
route |
string | Yes | Magento route path (e.g., module/controller/action) |
route_params |
array | No | Route parameters (e.g., section, id) |
sort_order |
number | No | Order of items (default: 0, lower = first) |
class |
string or array | No | CSS class(es) for the menu item, or a map of class name to boolean flag |
icon |
string | No | SVG or HTML icon content |
is_active |
boolean | No | Show/hide item (default: true) |
resource |
string | No | ACL resource id the admin user needs to see the item (since 2.2.0) |
group |
string | No | Code of the group the item is listed in (since 2.3.0) |
An item that holds a value of the wrong type (for example an array as label, or an empty resource) is left out
of the menu and logged as a warning; the rest of the menu still renders.
Per-item Permissions (resource):
Give an item the ACL resource that guards the page it links to. The menu checks it with
Magento\Framework\AuthorizationInterface::isAllowed() and does not render the item for an admin role that lacks
it, so a restricted user never sees a link that ends on "Sorry, you need permissions to view this content".
Items without resource are shown to everyone who can open the page, exactly as in 2.1. When no item is left, the
menu renders nothing.
<item name="settings" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Settings</item>
<item name="route" xsi:type="string">adminhtml/system_config/edit</item>
<item name="route_params" xsi:type="array">
<item name="section" xsi:type="string">mymodule</item>
</item>
<item name="resource" xsi:type="string">Vendor_MyModule::config</item>
<item name="sort_order" xsi:type="number">20</item>
</item>
The per-item check hides links; it does not protect the pages. Each controller still declares its own
ADMIN_RESOURCE.
Groups and Columns (since 2.3.0):
A long menu reads better in titled groups. Declare the groups in the groups argument (code → label and
sort_order) and name a group in each item's group key:
<block class="Hryvinskyi\Base\Block\Adminhtml\Menu" name="my.module.menu">
<arguments>
<argument name="menu_title" xsi:type="string" translate="true">My Module</argument>
<argument name="groups" xsi:type="array">
<item name="reports" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Reports</item>
<item name="sort_order" xsi:type="number">10</item>
</item>
<item name="settings" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Settings</item>
<item name="sort_order" xsi:type="number">20</item>
</item>
</argument>
<argument name="max_items_per_column" xsi:type="number">6</argument>
<argument name="items" xsi:type="array">
<item name="sales" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Sales</item>
<item name="route" xsi:type="string">mymodule/reports/sales</item>
<item name="group" xsi:type="string">reports</item>
<item name="sort_order" xsi:type="number">10</item>
</item>
<item name="general" xsi:type="array">
<item name="label" xsi:type="string" translate="true">General</item>
<item name="route" xsi:type="string">adminhtml/system_config/edit</item>
<item name="route_params" xsi:type="array">
<item name="section" xsi:type="string">mymodule</item>
</item>
<item name="group" xsi:type="string">settings</item>
<item name="resource" xsi:type="string">Vendor_MyModule::config</item>
</item>
</argument>
</arguments>
</block>
| Argument | Type | Description |
|---|---|---|
groups |
array | Group code → label (required, translatable) and sort_order (number, default 0) |
max_items_per_column |
number | Most items in one column of a group (default 6) |
- Groups are shown side by side in
sort_order(equal sort orders keep the declared order), each under its
heading; a group wraps to a new row when the screen is too narrow, and below 768 px everything stacks. - Inside a group, items keep their
sort_order. A group none of whose items the admin may see (denied by
resource, oris_activefalse) is not shown. - A group longer than
max_items_per_columnis split into even columns, the first columns taking the extra item:
with the default of 6, 7 items become 4 + 3 and 13 items 5 + 4 + 4. - Items without
group, or naming a group the menu does not declare, are listed after every group without a
heading. An undeclared code is logged as a warning on every page that renders the menu, so fix it rather than
leave it. - A menu without
groups, or whose items name none, is the single list it always was. - Any module can add its own entries to another module's groups from its own layout file by naming the group's
code; only the menu's owner declares the groups. - A malformed group (no label, a value of the wrong type) or a malformed
max_items_per_columnis logged and
ignored, like a malformed item.
Custom Menu Icon:
<argument name="menu_icon" xsi:type="string"><![CDATA[
<svg class="hamburger-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
]]></argument>
Menu Item with Icon:
<item name="reports" xsi:type="array">
<item name="label" xsi:type="string" translate="true">Reports</item>
<item name="route" xsi:type="string">mymodule/reports/index</item>
<item name="sort_order" xsi:type="number">30</item>
<item name="icon" xsi:type="string"><![CDATA[
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path d="M3 3v18h18v-18h-18zm16 16h-14v-14h14v14z"/>
</svg>
]]></item>
</item>
Services
Base publishes its utilities as small role interfaces under Api/, implemented under Model/ and wired in
etc/di.xml. Each interface answers one question, so a class depends on the role it uses and nothing else. The
interfaces are @api; the services are stateless and shared; no public method changes an argument by reference.
Inject the interface you need:
use Hryvinskyi\Base\Api\Arrays\ArrayPathReaderInterface;
use Hryvinskyi\Base\Api\Html\HtmlEncoderInterface;
use Hryvinskyi\Base\Api\Html\TagRendererInterface;
class ProductBadge
{
public function __construct(
private readonly ArrayPathReaderInterface $arrayPathReader,
private readonly HtmlEncoderInterface $htmlEncoder,
private readonly TagRendererInterface $tagRenderer
) {
}
public function render(array $product): string
{
$label = (string)$this->arrayPathReader->getValue($product, 'badge.label', 'New');
return $this->tagRenderer->tag('span', $this->htmlEncoder->encode($label), ['class' => 'badge']);
}
}
Arrays
Interface (Hryvinskyi\Base\Api\Arrays\) |
Methods |
|---|---|
ArrayPathReaderInterface |
getValue($source, $key, $default) reads a dot path; keyExists($key, $array, $caseSensitive) |
ArrayPathWriterInterface |
setValue($array, $path, $value), remove($array, $key), removeValue($array, $value), valuesIdenticalTo($array, $value); each returns an array |
ArrayMergerInterface |
merge($first, ...$others) merges recursively |
ArrayIndexerInterface |
index($array, $key, $groups), getColumn($array, $name, $keepKeys), map($array, $from, $to, $group) |
ArraySorterInterface |
multisort($array, $key, $direction, $sortFlag) returns the sorted rows |
ArrayFilterInterface |
filter($array, $filters) keeps or drops dot paths |
ArrayInspectorInterface |
isAssociative(), isIndexed(), isIn(), isTraversable(), isSubset() |
ArrayHtmlEncoderInterface |
htmlEncode($data, $valuesOnly), htmlDecode($data, $valuesOnly) |
ObjectToArrayConverterInterface |
toArray($object, $properties, $recursive) |
Api\Data\ArrayableInterface lets an object describe its own array form. Two markers steer a merge: an
Api\Data\UnsetArrayValueInterface removes a key, an Api\Data\ReplaceArrayValueInterface replaces a value instead of
merging into it. Both come from their generated factories, and a replacement carries its value through withValue():
$merged = $this->arrayMerger->merge(
['items' => ['a', 'b'], 'debug' => true],
[
'items' => $this->replaceArrayValueFactory->create()->withValue(['c', 'd']),
'debug' => $this->unsetArrayValueFactory->create(),
]
);
// ['items' => ['c', 'd']]
HTML
Interface (Hryvinskyi\Base\Api\Html\) |
Methods |
|---|---|
HtmlEncoderInterface |
encode(), encodeKeepingEntities(), decode() |
AttributeRendererInterface |
render($attributes) |
TagRendererInterface |
tag($name, $content, $options), beginTag(), endTag() |
CssOptionsInterface |
addCssClass(), removeCssClass(), addCssStyle(), removeCssStyle(), each returning the new options; cssStyleFromArray(), cssStyleToArray() |
InputNameResolverInterface |
getAttributeName(), getInputIdByName() |
JsRegexConverterInterface |
convert($regexp) |
ResourceTagRendererInterface |
style(), script(), cssFile(), jsFile() |
LinkRendererInterface |
a(), mailto(), img() |
FormRendererInterface |
beginForm(), endForm(), label(), button(), submitButton(), resetButton(), textarea() |
InputRendererInterface |
input(), buttonInput(), submitInput(), resetInput(), textInput(), hiddenInput(), passwordInput(), fileInput(), radio(), checkbox() |
ChoiceListRendererInterface |
dropDownList(), listBox(), checkboxList(), radioList(), renderSelectOptions() |
ListRendererInterface |
ul(), ol() |
The tag rendering keeps a fixed attribute order, renders data, aria, class and style arrays, skips null
attributes and closes void elements with />. The order and the lists are required etc/di.xml arguments with no
default in the classes, so a project changes them there. An array merge can override an item but not remove it, so a void element is switched off with false:
<type name="Hryvinskyi\Base\Model\Html\TagRenderer">
<arguments>
<argument name="voidElements" xsi:type="array">
<item name="command" xsi:type="boolean">false</item>
</argument>
</arguments>
</type>
The class normalisation switch is read in two places: set normalizeClassAttribute to the same value on
Model\Html\AttributeRenderer and Model\Html\CssOptions.
JSON
Api\Json\JsonEncoderInterface::encode($value, $options) and htmlEncode($value), the latter safe inside an HTML
attribute; Api\Json\JsonDecoderInterface::decode($json, $asArray). Malformed input throws
Hryvinskyi\Base\Exception\InvalidParamException with PHP's message and json_last_error() code; null and an empty
string decode to null.
$json = $this->jsonEncoder->encode(['name' => 'John'], JSON_PRETTY_PRINT);
$safe = $this->jsonEncoder->htmlEncode(['alert' => '<script>alert("xss")</script>']);
$data = $this->jsonDecoder->decode($json);
VarDumper
To look at a value while debugging, call hdump($var, $depth, $highlight) or hdd(...) from anywhere, a template
included, with no import: they print Base's dump (below), hdump() returns the value and hdd() stops the script.
Highlighting defaults to on everywhere but the command line. Symfony's dump() and dd() work too: Base requires
symfony/var-dumper.
They are debugging tools with no check of their own: unlike the layout debug tools they print in production mode too, to
every visitor, so one left in a template ends up in the page cache.
For a dump the code keeps, such as a log entry or a report,
Api\Debug\VarDumperInterface::dumpAsString($var, $depth, $highlight) returns a readable dump that survives circular
references; with $highlight it is HTML with every token escaped, so a dump of visitor data is safe to print.
export($var) returns a PHP expression: objects as unserialize('…'), closures as their source.
$logger->debug($this->varDumper->dumpAsString($data, 5));
$code = $this->varDumper->export(['name' => 'John', 'items' => [1, 2, 3]]);
Upgrading from 2.x
3.0 removes every static class in Helper/. Inject the interface of the table below instead of calling the static; the
Api\… names are under Hryvinskyi\Base\. Nothing replaces ConsoleHelper, which had no consumers. A package that
still calls a static cannot load against 3.0, so composer.json declares a conflict with the released versions of
hryvinskyi/magento2-head-tag-manager, -page-speed, -page-speed-js-merge and -error-reporting that do.
Classes
| 2.x | 3.0 |
|---|---|
Helper\Config::isEnabledLayoutDebug() |
Api\LayoutDebugConfigInterface::isEnabledLayoutDebug(), which also requires a visitor the developer restriction allows |
Helper\Arrayable |
Api\Data\ArrayableInterface, with typed signatures: fields(): array, extraFields(): array, toArray(array $fields = [], array $expand = [], bool $recursive = true): array |
new Helper\UnsetArrayValue() |
Api\Data\UnsetArrayValueInterfaceFactory::create() |
new Helper\ReplaceArrayValue($value) |
Api\Data\ReplaceArrayValueInterfaceFactory::create()->withValue($value) |
Helper\InvalidParamException (a \BadMethodCallException) |
Exception\InvalidParamException, now an \InvalidArgumentException: a catch (\BadMethodCallException) no longer sees it. getName() is gone |
Helper\InvalidValueException |
Exception\InvalidValueException, still an \UnexpectedValueException. getName() is gone |
Helper\ConsoleHelper |
removed |
The markers have no __set_state().
Json
| 2.x | 3.0 |
|---|---|
Json::encode($value, $options) |
Api\Json\JsonEncoderInterface::encode() |
Json::htmlEncode($value) |
Api\Json\JsonEncoderInterface::htmlEncode() |
Json::decode($json, $asArray) |
Api\Json\JsonDecoderInterface::decode(), which takes ?string |
Json::$prettyPrint |
pass JSON_PRETTY_PRINT in $options |
Json::$keepObjectType |
the keepObjectType constructor argument of Model\Json\JsonEncoder, set in di.xml |
Json::$jsonErrorMessages |
removed; the exception carries PHP's json_last_error_msg() |
VarDumper
| 2.x | 3.0 |
|---|---|
VarDumper::dump($var, $depth, $highlight) |
hdump($var, $depth, $highlight): global, no import; it prints and returns the value. hdd() prints and stops. Symfony's dump()/dd() also work |
VarDumper::dumpAsString($var, $depth, $highlight) |
Api\Debug\VarDumperInterface::dumpAsString() |
VarDumper::export($var) |
Api\Debug\VarDumperInterface::export() |
The highlighted form no longer comes from highlight_string(): it is <pre><code style="color: #000000"> with one
coloured span per token. Its text equals the plain dump and every token is escaped.
ArrayHelper
| 2.x | 3.0 |
|---|---|
ArrayHelper::getValue($array, $key, $default) |
Api\Arrays\ArrayPathReaderInterface::getValue() |
ArrayHelper::keyExists($key, $array, $caseSensitive) |
Api\Arrays\ArrayPathReaderInterface::keyExists() |
ArrayHelper::setValue(&$array, $path, $value) |
$array = Api\Arrays\ArrayPathWriterInterface::setValue($array, $path, $value); a null path is gone, use the value itself |
$old = ArrayHelper::remove(&$array, $key, $default) |
read first, $old = array_key_exists($key, $array) ? $array[$key] : $default, then $array = ArrayPathWriterInterface::remove($array, $key). Not getValue(), which falls through to a dot path when the literal key is absent. A null array is a TypeError |
$old = ArrayHelper::removeValue(&$array, $value) |
read first, $old = ArrayPathWriterInterface::valuesIdenticalTo($array, $value), then $array = ArrayPathWriterInterface::removeValue($array, $value). A null array is a TypeError |
ArrayHelper::merge($a, $b, ...) |
Api\Arrays\ArrayMergerInterface::merge(); the first argument is an array and the others arrays or objects, so a scalar or null is a TypeError instead of being skipped |
ArrayHelper::index($array, $key, $groups) |
Api\Arrays\ArrayIndexerInterface::index(); a lone Closure as $groups is refused, wrap it in a list |
ArrayHelper::getColumn($array, $name, $keepKeys) |
Api\Arrays\ArrayIndexerInterface::getColumn() |
ArrayHelper::map($array, $from, $to, $group) |
Api\Arrays\ArrayIndexerInterface::map() |
ArrayHelper::multisort(&$array, $key, $direction, $sortFlag) |
$array = Api\Arrays\ArraySorterInterface::multisort($array, $key, $direction, $sortFlag) |
ArrayHelper::filter($array, $filters) |
Api\Arrays\ArrayFilterInterface::filter() |
ArrayHelper::toArray($object, $properties, $recursive) |
Api\Arrays\ObjectToArrayConverterInterface::toArray() |
ArrayHelper::htmlEncode($data, $valuesOnly, $charset) |
Api\Arrays\ArrayHtmlEncoderInterface::htmlEncode($data, $valuesOnly), always UTF-8 |
ArrayHelper::htmlDecode($data, $valuesOnly) |
Api\Arrays\ArrayHtmlEncoderInterface::htmlDecode() |
ArrayHelper::isAssociative(), isIndexed(), isIn(), isTraversable(), isSubset() |
the same methods of Api\Arrays\ArrayInspectorInterface |
Html
| 2.x | 3.0 |
|---|---|
Html::encode($content) |
Api\Html\HtmlEncoderInterface::encode() |
Html::encode($content, false) |
Api\Html\HtmlEncoderInterface::encodeKeepingEntities() |
Html::decode($content) |
Api\Html\HtmlEncoderInterface::decode() |
Html::renderTagAttributes($attributes) |
Api\Html\AttributeRendererInterface::render() |
Html::tag(), beginTag(), endTag() |
the same methods of Api\Html\TagRendererInterface; the name is ?string, false is no longer accepted |
Html::addCssClass(&$options, $class) |
$options = Api\Html\CssOptionsInterface::addCssClass($options, $class) |
Html::removeCssClass(&$options, $class) |
$options = Api\Html\CssOptionsInterface::removeCssClass($options, $class) |
Html::addCssStyle(&$options, $style, $overwrite) |
$options = Api\Html\CssOptionsInterface::addCssStyle($options, $style, $overwrite) |
Html::removeCssStyle(&$options, $properties) |
$options = Api\Html\CssOptionsInterface::removeCssStyle($options, $properties) |
Html::cssStyleFromArray(), cssStyleToArray() |
the same methods of Api\Html\CssOptionsInterface |
Html::getAttributeName(), getInputIdByName() |
the same methods of Api\Html\InputNameResolverInterface |
Html::escapeJsRegularExpression($regexp) |
Api\Html\JsRegexConverterInterface::convert() |
Html::style(), script(), cssFile(), jsFile() |
the same methods of Api\Html\ResourceTagRendererInterface |
Html::a(), mailto(), img() |
the same methods of Api\Html\LinkRendererInterface |
Html::beginForm(), endForm(), label(), button(), submitButton(), resetButton(), textarea() |
the same methods of Api\Html\FormRendererInterface |
Html::input(), buttonInput(), submitInput(), resetInput(), textInput(), hiddenInput(), passwordInput(), fileInput(), radio(), checkbox() |
the same methods of Api\Html\InputRendererInterface |
Html::dropDownList(), listBox(), checkboxList(), radioList() |
the same methods of Api\Html\ChoiceListRendererInterface |
Html::renderSelectOptions($selection, $items, &$tagOptions) |
Api\Html\ChoiceListRendererInterface::renderSelectOptions(), which leaves its options alone: a caller rendering its own <select> strips ChoiceListRendererInterface::SELECT_OPTION_KEYS from them |
Html::ul(), ol() |
the same methods of Api\Html\ListRendererInterface; the items are iterable, so null is a TypeError |
Html::$attributeOrder, Html::$dataAttributes |
the attributeOrder and dataAttributes arguments of Model\Html\AttributeRenderer in di.xml |
Html::$normalizeClassAttribute |
the normalizeClassAttribute argument of both Model\Html\AttributeRenderer and Model\Html\CssOptions, with the same value |
Html::$voidElements |
the voidElements argument of Model\Html\TagRenderer in di.xml |
Html::$attributeRegex |
the attributeRegex argument of Model\Html\InputNameResolver in di.xml |
The services declare strict_types, so an item callback of a list receives the raw key: an int for a numeric key.
A callback that types the key string has to be widened.
Requirements
- PHP >= 8.1 with the tokenizer extension
- Magento 2.x (2.4+ recommended)
License
This module contains code from two sources with dual licensing:
Yii2 Framework Code (BSD-3-Clause License)
The array, HTML, JSON and dump services (Model/Arrays, Model/Html, Model/Json, Model/Debug and their interfaces) are derived from the Yii2 Framework and are licensed under the BSD-3-Clause License.
- Copyright: © 2008 Yii Software LLC
- License: BSD-3-Clause
- License File: See LICENSE-YII for the full license text
- Original Source: https://github.com/yiisoft/yii2
Magento 2 Modifications (MIT License)
Modifications, adaptations for Magento 2 compatibility, and original features (ViewModelRegistry, ViewModelCacheTags, layout debugging, etc.) are licensed under the MIT License.
- Copyright: © 2019-2024 Volodymyr Hryvinskyi
- License: MIT
- License File: See LICENSE for the full license text
Usage Rights
You are free to use, modify, and distribute this module under the terms of both licenses. The BSD-3-Clause license applies to the Yii2-derived code, and the MIT license applies to the Magento 2 adaptations and original features.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.
[3.1.1] - 2026-11-09
Fixed
View\Helper\SecureHtmlRender\HtmlRendererrenders an attribute given asnullwithout a value
($secureRenderer->renderTag('script', ['data-x' => null], ...)gives<script data-x>), as 2.1 did. Since 2.2 the
attribute was dropped, while Magento's own renderer keeps it asdata-x=""; a template that marks its tag this way,
such as a script that another module must leave alone, lost its marker. The same fix is released for 2.x as 2.3.2.
[3.1.0] - 2026-11-08
Added
hdump($var, $depth, $highlight)andhdd(...): global debug functions that print Base's dump from anywhere, with no
import or injection, as the 2.x staticVarDumper::dump()could.hdump()returns the value;hdd()sends a 500 to
a browser, so no page cache stores the page, and stops. Highlighting defaults to on everywhere but the command line.
Before Magento has started they print the value withprint_r(). The module's registration loads them, so they
exist whether Base comes from Composer or fromapp/code. They print in production mode too: they are not for
committed code.phpcs.xml.dist: the Magento2 standard, with printing and stopping allowed infunctions.phpand the require in
registration.php.
[3.0.0] - 2026-11-07
The static helpers become injected services. See "Upgrading from 2.x" in the README for the table of every old call.
Added
- Role interfaces under
Api/, each@api, implemented underModel/and wired inetc/di.xml:Api\Json
(JsonEncoderInterface,JsonDecoderInterface),Api\Debug\VarDumperInterface,Api\Arrays
(ArrayPathReaderInterface,ArrayPathWriterInterface,ArrayMergerInterface,ArrayIndexerInterface,
ArraySorterInterface,ArrayInspectorInterface,ArrayFilterInterface,ObjectToArrayConverterInterface,
ArrayHtmlEncoderInterface),Api\Html(HtmlEncoderInterface,AttributeRendererInterface,
TagRendererInterface,CssOptionsInterface,InputNameResolverInterface,JsRegexConverterInterface,
ResourceTagRendererInterface,LinkRendererInterface,FormRendererInterface,InputRendererInterface,
ChoiceListRendererInterface,ListRendererInterface),Api\Data(ArrayableInterface,
UnsetArrayValueInterface,ReplaceArrayValueInterface) andApi\LayoutDebugConfigInterface. Html::$attributeOrder,$dataAttributes,$normalizeClassAttribute,$voidElementsand$attributeRegexare
required constructor arguments set inetc/di.xml, so a project changes them there. The classes hold no default:
etc/di.xmlis their one source, a mistyped argument fails in the container, and code building one of these classes
by hand passes them.conflictwith the released versions of head-tag-manager (<3.0), page-speed and page-speed-js-merge (<1.0.8) and
error-reporting (<1.1), which call the removed statics.
Removed
Helper\Html: replaced by theApi\Htmlservices.Helper\ArrayHelper: replaced by theApi\Arraysservices.Helper\Json: replaced byApi\Json\JsonEncoderInterfaceandJsonDecoderInterface.$prettyPrintand
$jsonErrorMessagesare gone: passJSON_PRETTY_PRINT, and read PHP's message on the exception.Helper\VarDumper: replaced byApi\Debug\VarDumperInterface.dump()is replaced by the global
dump()anddd()ofsymfony/var-dumper, now a declared requirement, which work anywhere with no import.Helper\Config: replaced byApi\LayoutDebugConfigInterface.Helper\Arrayable: replaced byApi\Data\ArrayableInterface, with typed signatures.Helper\ReplaceArrayValue,Helper\UnsetArrayValue: replaced byApi\Data\ReplaceArrayValueInterfaceand
UnsetArrayValueInterface, created by their generated factories, without__set_state(). A replacement carries its
value throughwithValue().Helper\ConsoleHelper, with no replacement.Helper\InvalidParamException,Helper\InvalidValueException: moved toException\.
Changed
InvalidParamExceptionextends\InvalidArgumentException(it was a\BadMethodCallException);
InvalidValueExceptionstill extends\UnexpectedValueException. Neither hasgetName()any more.- No public method changes an argument by reference.
ArrayHelper::setValue(),remove(),removeValue()andmultisort()
and theHtmlCSS option methods return the new value. Whatremove()returned is read first with a literal key
read (array_key_exists, notgetValue(), which falls through to a dot path); whatremoveValue()returned comes
fromvaluesIdenticalTo(). renderSelectOptions()leaves its options alone: a caller rendering its own<select>strips
ChoiceListRendererInterface::SELECT_OPTION_KEYS.setValue()has nonullpath,htmlEncode()has no charset,index()no longer accepts a lone Closure as
$groups.remove()andremoveValue()take an array:nullis aTypeError, where 2.x left it alone and returned the
default.merge()takes an array first and an array or object after it: a scalar ornullis aTypeError, where 2.x
skipped it.Html::encode($x, false)isHtmlEncoderInterface::encodeKeepingEntities(). Both encoders take whatHtml::encode()
took:string|int|float|bool|\Stringable|null, cast to a string, sonullgives''.tag(),beginTag()andendTag()take?string;falseis no longer accepted.ul()andol()takeiterable,
sonullis aTypeError.Json::decode()takes?string;keepObjectTypeis a constructor argument ofModel\Json\JsonEncoder.- The highlighted dump no longer comes from
highlight_string():<pre><code style="color: #000000">with one
coloured span per token, the same text, every token escaped. - The services declare
strict_types: anitemcallback receives the raw key, anintfor a numeric key, so a
callback typedstringneeds widening. - Requires
magento/module-developerandmagento/module-store, whichPlugin\DebugHintsuses,
magento/module-page-cache, whichModel\ViewModelRegistryuses,magento/module-config, which
etc/adminhtml/system.xmluses, andext-tokenizer, which the closure export and the highlighted dump use; sequences
Magento_Config,Magento_Developer,Magento_PageCacheandMagento_Store. Plugin\LayoutandPlugin\DebugHintstakeMagento\Framework\App\RequestInterfaceinstead of the concrete HTTP
request.phpstan.neon.distanalysesException/and holds the code to the PHP 8.1 floor (phpVersion: 80100).
Fixed
LayoutDebugConfigInterface::isEnabledLayoutDebug()also requires a visitor the developer restriction allows, so
?xml=1, the recorded layout and the page cache skip follow the rule the template hints already had: on a
developer-mode host, a visitor outside the allow-list no longer gets them.Plugin\DebugHintsreads the current store only when it is about to show hints, not on every template engine
creation.
[2.3.1] - 2026-11-07
Fixed
VarDumper::export()of a multi-line closure no longer doubles every line break of its source.VarDumper::dump()/dumpAsString()with highlighting no longer print the<?phptag on PHP 8.3 and later.ArrayHelper::filter(): a rule whose path runs through a value that is not an array matches nothing, as
documented, instead of throwing aTypeError; the same for!exclude rules.ArrayHelper::toArray(): a property mapping with an integer key and a Closure value stores the Closure's result
instead of throwing.Model\Layout\LayoutXmlstarts without a layout (null) rather than relying on an untyped property, and
NoSetLayoutExceptionbuilds its default message with__().
Changed
Htmlrefuses a value it cannot print as text (an array, a resource, an object without__toString()) as a list
item, a label, anitemcallback's result or an attribute value withInvalidParamException; such a value used
to print as "Array" with a warning or end in anError. A non-callableitemoption ofcheckboxList(),
radioList()orul()throwsInvalidParamExceptioninstead of aTypeError. An option that must be an array
(labelOptions,itemOptions,groups, an option's attributes, the prompt's options) is ignored when it is not.ArrayHelper::index()andmap()reject a key or group value that is not a scalar or null with an explicit
TypeErrormessage; a float group key is cast to an integer without PHP 8.1's precision deprecation.ArrayHelper::merge()skips an argument that is neither an array nor an object (it used to warn), and throws
InvalidParamExceptionwhen a Traversable yields a key that is neither an integer nor a string.ConsoleHelper::prompt()no longer readsUnsetArrayValue/ReplaceArrayValuemarkers in its options;
ConsoleHelper::streamSupportsAnsiColors()returns false for a closed stream instead of throwing.- Every public method of the helpers carries exact phpdoc types (no native types were added, so callers and
subclasses keep working), the Yii-only documentation (Url::to(), CSRF,Yii::$app) is gone, and
phpstan.neon.distcovers the whole module at level 10. New unit tests coverConsoleHelper,Json,
LayoutXml,ViewModelRegistryand the secure HTML renderer.
[2.3.0] - 2026-11-07
Added
- Admin menu groups. The new
groupslayout argument declares titled groups (code →label,sort_order), and an
item joins one with the newgroupkey. Groups are listed by sort order, each under its heading, side by side;
a group none of whose items the admin may see (denied by ACL oris_activefalse) is not shown. Items naming no
group, or a group the menu does not declare, follow every group untitled; an undeclared code is logged as a
warning on each page that renders the menu. A menu that declares no group, or whose items name none, renders
the same single list as before. - Columns for long groups: the new
max_items_per_columnlayout argument (default 6) splits a longer group into
even columns (13 items → 5, 4, 4). Under 768 px the groups and columns stack. Api\Menu\MenuGroupInterfaceandApi\Menu\MenuGroupFactoryInterface(creates a group from layout
configuration; a group without a label, or with a value of the wrong type, is skipped and logged).Api\Menu\GroupedMenuItemInterface(getGroup()), implemented by the built-in menu item.MenuItemInterfaceis
unchanged: existing implementations keep working and are listed ungrouped.- Module
phpunit.xml.dist(unit suite, with a bootstrap that generates…Factorystubs on demand) and
phpstan.neon.dist(level 10, the menu code and its tests); integration tests rendering the menu through the
real layout.
Changed
- Menu items are created through Magento's generated factory instead of
new;MenuItemFactoryonly validates the
configuration. The item factory receives scalar values only and the route parameters reach the item after it is
created. (The block's own layoutdata,itemsincluded, still goes through the object manager when the layout
builds the block, as for every block.)MenuItemno longer takesrouteParamsin its constructor (usewithRouteParams()) and takes
groupas its last parameter. - An item with
is_activefalse is left out by the block rather than by the template.getMenuItems()still returns
the allowed items, inactive ones included;hasItems()now counts only the items shown, so a menu whose items
are all inactive renders nothing instead of an empty dropdown. - The block's constructor takes the group factory and the section arranger before
$data. Neither the block nor
MenuItemis@api: a subclass or a direct caller of either constructor must follow these signatures. - A group code is letters, digits,
_and-; any other code is rejected (the group is skipped and logged), so two
codes can never give the same heading id.
Removed
- A stylesheet rule that could never match (the menu is not inside the toggle button).
Fixed
Html::renderTagAttributes()(and every tagHtmlbuilds) again leaves out an attribute whose value isnull, as
documented. Since 2.1.11 such an attribute was printed bare:Html::textInput('q')gave
<input type="text" name="q" value />andHtml::label('Name')gave<label for>. Usetruefor a boolean
attribute.- The
Html,ArrayHelperandVarDumperunit tests run on PHPUnit 10 and assert this module's own output (void
elements closed with/>,beginForm()printing the method as given,a()printing the URL as given).
[2.2.0] - 2026-09-25
Added
- Admin menu: per-item
resourcekey. An item whose ACL resource the current admin user is not allowed is not
rendered; items withoutresourcebehave as before.hasItems()counts only the items left after the check, so
a menu with no permitted item renders nothing. MenuItemInterface::getResource(): ?string. Custom implementations of the interface must add it.
Changed
- Admin menu items are read with their types checked. An item holding a value of the wrong type is skipped and
logged as a warning instead of failing with aTypeError.MenuItemFactoryInterface::create()documents the
\InvalidArgumentExceptionit throws for such an item. - Boolean item flags (
is_active, the values of aclassmap) accept"true"/"false"/"1"/"0"; the string
"false"no longer counts as true. composer.jsondeclaresphp >=8.1(the code already needs readonly properties),magento/framework ^103.0
andmagento/module-backend ^102.0;module.xmlsequencesMagento_Backend.
Fixed
menu.phtml: the inline style tag is printed through an explicit@noEscapeecho.AddMenuHandleToConfigPagereads the event'sfull_action_nameandlayoutwith their types checked.
[2.1.12] - 2026-06-04
Fixed
- PHP 8.5 compatibility: use explicit nullable type for the
$blockparameter inViewModelRegistry::require()(AbstractBlock $block = null→?AbstractBlock $block = null). Implicitly marking a typed parameter as nullable is deprecated since PHP 8.4 and emits a deprecation notice on PHP 8.5.
[2.1.7] - 2026-02-04
Fixed
- Renamed
$classparameter to$cssClassinMenuItemconstructor to avoid PHP reserved keyword conflict that caused DI compilation failure
[2.1.6] - 2026-02-02
Added
- Admin Menu Component - A reusable dropdown menu block for Magento admin pages
Hryvinskyi\Base\Block\Adminhtml\Menu- Block class with layout XML configuration supportHryvinskyi\Base\Api\Menu\MenuItemInterface- Interface for menu itemsHryvinskyi\Base\Api\Menu\MenuItemFactoryInterface- Factory interface for creating menu itemsHryvinskyi\Base\Model\Menu\MenuItem- Menu item implementation with route-based URL generationHryvinskyi\Base\Model\Menu\MenuItemFactory- Factory for creating menu items from array configurationview/adminhtml/templates/menu.phtml- Admin menu template
- Menu items support:
- Route-based URL generation with parameters
- Sortable items via
sort_order - Custom CSS classes per item
- SVG/HTML icons per item
- Translatable labels
- Active/inactive state
Changed
- Admin CSS styles now include menu component styles in
view/adminhtml/web/css/styles.css
[2.1.5] - Previous Release
Features
- ViewModel Registry (
$viewModels) - Global access to view models in templates - Layout Debugging tools for developer mode
- Yii2 Framework Helpers:
- ArrayHelper - Array manipulation utilities
- Html - HTML generation helper
- Json - JSON encoding/decoding utilities
- VarDumper - Variable dumping for debugging
- ConsoleHelper - ANSI terminal control for CLI commands
| Version | Stability | QA Status | Compatibility | Released |
|---|---|---|---|---|
| 3.1.1 | stable | Fail | Magento 2.4.7-2.4.9 Details | 2026-11-09 09:57:18 |
| 2.3.2 | stable | Not tested | Not yet tested Details | 2026-11-09 09:55:52 |
| 3.1.0 | stable | Fail | Magento 2.4.7-2.4.9 Details | 2026-11-08 07:14:51 |
| 3.0.0 | stable | Fail | Magento 2.4.7-2.4.9 Details | 2026-11-07 19:39:53 |
| 2.3.1 | stable | Fail | Magento 2.4.7-2.4.9 Details | 2026-11-07 12:38:39 |
| 2.3.0 | stable | Not tested | Not yet tested Details | 2026-11-07 11:35:09 |
| 2.2.0 | stable | Fail | Magento 2.4.7-2.4.9 Details | 2026-09-25 03:21:09 |
| 2.1.12 | stable | Fail | Not yet tested Details | 2026-06-04 07:57:01 |
| 2.1.11 | stable | Not tested | Not yet tested Details | 2026-04-17 11:03:25 |
| 1.0.10 | stable | Not tested | Not yet tested Details | 2026-03-25 08:56:55 |
| 2.1.9 | stable | Not tested | Not yet tested Details | 2026-02-17 22:31:55 |
| 2.1.8 | stable | Not tested | Not yet tested Details | 2026-02-17 21:52:38 |
| 2.1.7 | stable | Not tested | Not yet tested Details | 2026-02-03 23:06:10 |
| 2.1.6 | stable | Not tested | Not yet tested Details | 2026-02-03 23:05:47 |
| 2.1.5 | stable | Not tested | Not yet tested Details | 2025-10-06 16:05:41 |
| 2.1.4 | stable | Not tested | Not yet tested Details | 2025-10-06 16:05:23 |
| 2.1.3 | stable | Not tested | Not yet tested Details | 2024-12-17 15:41:53 |
| 2.1.2 | stable | Not tested | Not yet tested Details | 2024-10-17 13:07:42 |
| 2.1.1 | stable | Not tested | Not yet tested Details | 2022-06-09 10:10:23 |
| 2.1.0 | stable | Not tested | Not yet tested Details | 2020-09-07 12:40:32 |
| 2.0.1.1 | stable | Not tested | Not yet tested Details | 2020-01-30 09:31:00 |
| 2.0.1 | stable | Not tested | Not yet tested Details | 2019-04-22 14:43:28 |
| 2.0.0 | stable | Not tested | Not yet tested Details | 2019-03-21 21:39:58 |
| 1.1.2 | stable | Not tested | Not yet tested Details | 2018-09-18 09:10:58 |
| 1.1.1 | stable | Not tested | Not yet tested Details | 2018-03-08 08:21:34 |
| 1.1.0 | stable | Not tested | Not yet tested Details | 2018-01-03 10:04:56 |
| 1.0.2 | stable | Not tested | Not yet tested Details | 2017-12-19 16:46:32 |
| 1.0.1 | stable | Not tested | Not yet tested Details | 2017-11-21 09:37:30 |
| 1.0.0 | stable | Not tested | Not yet tested Details | 2017-11-05 16:25:31 |
| 0.0.1 | stable | Not tested | Not yet tested Details | 2017-07-25 08:03:43 |
Requires 9
| Package | Constraint |
|---|---|
| ext-tokenizer | * |
| magento/framework | ^103.0 |
| magento/module-backend | ^102.0 |
| magento/module-config | ^101.2 |
| magento/module-developer | ^100.4 |
| magento/module-page-cache | ^100.4 |
| magento/module-store | ^101.1 |
| php | >=8.1 |
| symfony/var-dumper | ^5.4 || ^6.0 || ^7.0 || ^8.0 |
Requires-dev 1
| Package | Constraint |
|---|---|
| symfony/process | * |
Conflicts 4
| Package | Constraint |
|---|---|
| hryvinskyi/magento2-error-reporting | <1.1 |
| hryvinskyi/magento2-head-tag-manager | <3.0 |
| hryvinskyi/magento2-page-speed | <1.0.8 |
| hryvinskyi/magento2-page-speed-js-merge | <1.0.8 |
Compatibility
Each Magento release line is installed on its supported PHP versions, then the module is built (DI compilation + static-content deploy) and its unit and integration suites are run. The matrix shows the lines and PHP versions the module is confirmed to install and run on. Code-quality results further down (phpstan, phpcs, …) are reported separately and never affect compatibility.
Code Quality
Advisory checks against the module's source. Static analysis runs once across the whole module; PHPStan re-runs per Magento + PHP version because resolvable symbols differ between releases. These NEVER affect the Compatibility badge. A phpcs finding can't make a module incompatible.
Static analysis
Coding standards (phpcs), mess detection (phpmd), copy-pasted code (cpd), PHP cross-version compatibility, composer.json validity. Each runs once for the whole module.
| Tool | Status | Findings | Summary |
|---|---|---|---|
| PHPCS | Fail | 5 | 3 errors, 2 warnings (ruleset: Magento2) |
| PHPMD | Warning | 91 | 91 rule violations (MissingImport:55, TooManyPublicMethods:15, UnusedFormalParameter:10, NPathComplexity:3, TooManyMethods:2) |
| Cpd | Pass | 0 | |
| Composer validate | Info | 1 | valid; 1 advisory note (composer validate --strict) |
PHPStan
Type-checks the module's PHP against a real Magento install at the configured gate level. Re-runs per Magento and PHP version because resolvable symbols differ between releases.
Tests
Unit and integration suites, run for each applicable Magento and PHP version. A test failure speaks to the module's behaviour, not its compatibility with a Magento line, so it is reported here separately and never reddens the compatibility matrix.
Unit tests
Security
Security checks run directly against the module: an audit of its declared dependencies for known vulnerabilities (composer audit) and a scan of its source for malware and web-shell signatures. Each runs once. A malware detection fails the version outright.
More from hryvinskyi
View vendorTurn an existing module into recurring revenue.
If you already maintain a Magento 2 module on GitHub or GitLab, listing it on Packagento takes about five minutes. We mirror your tags, handle distribution signing, and route paid licenses through Stripe Connect, so you can keep shipping the way you already do.