Writing Custom Renderers and Custom Column Classes
Writing Custom Renderers and Custom Column Classes
~9 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Chapter 17 already built a custom column class for the actions column. This chapter generalizes the pattern: how do you decide whether a PHP column class is enough, or whether a custom JavaScript component is needed - and how do you build either one cleanly?
Two levels of custom rendering
- PHP column class (
prepareDataSource()) - reshapes the raw data per row before it reaches the browser. Enough for anything expressible as a plain text/HTML value or an extra data field. - Custom JavaScript component (KnockoutJS template +
uiComponent) - needed as soon as the browser itself needs interactivity (click handlers, dynamic reloading) beyond a simple link withconfirm.
For testimonials, the first level is enough: a rating column that renders the number 1-5 as a star string instead of a bare number.
A custom rating column
<?php
declare(strict_types=1);
namespace Mironsoft\Testimonial\Ui\Component\Listing\Column;
use Magento\Framework\Escaper;
use Magento\Framework\View\Element\UiComponent\ContextInterface;
use Magento\Framework\View\Element\UiComponentFactory;
use Magento\Ui\Component\Listing\Columns\Column;
/**
* Renders the numeric rating as a star string, e.g. "★★★★☆".
*/
class Rating extends Column
{
private const MAX_RATING = 5;
/**
* @param ContextInterface $context Rendering context.
* @param UiComponentFactory $uiComponentFactory Factory for nested UI components.
* @param Escaper $escaper Escapes the rendered star string for safe HTML output.
* @param array<string, mixed> $components Nested UI components.
* @param array<string, mixed> $data Additional UI Component data configuration.
*/
public function __construct(
ContextInterface $context,
UiComponentFactory $uiComponentFactory,
private readonly Escaper $escaper,
array $components = [],
array $data = [],
) {
parent::__construct($context, $uiComponentFactory, $components, $data);
}
/**
* Replaces the raw rating integer with a rendered star string.
*
* @param array<string, mixed> $dataSource Raw grid data source.
* @return array<string, mixed>
*/
public function prepareDataSource(array $dataSource): array
{
if (!isset($dataSource['data']['items'])) {
return $dataSource;
}
$fieldName = $this->getData('name');
foreach ($dataSource['data']['items'] as &$item) {
$rating = max(0, min(self::MAX_RATING, (int) $item[$fieldName]));
$stars = str_repeat('★', $rating) . str_repeat('☆', self::MAX_RATING - $rating);
$item[$fieldName] = $this->escaper->escapeHtml($stars);
}
return $dataSource;
}
}<column name="rating" class="Mironsoft\Testimonial\Ui\Component\Listing\Column\Rating">
<settings>
<filter>textRange</filter>
<label translate="true">Rating</label>
</settings>
</column>Achtung: $this->escaper->escapeHtml() isn't a formality here: grid JavaScript templates partly render column values unbound as HTML. A column renderer that returns user input (not just computed values like here) unescaped opens a stored XSS hole directly in the admin grid.
When is PHP no longer enough?
An example that would need a custom JavaScript component: clicking the star column to change the rating directly in the grid, without a reload. That would require a custom RequireJS module (extending Magento_Ui/js/grid/columns/column) plus its own KnockoutJS template - noticeably more effort than the PHP renderer above, and outside the scope of this series. Chapter 20 shows inline edit as a ready-made, already built-in middle ground for exactly this use case.
The boundary, restated
As already mentioned in chapter 1: Rating here is a textbook example of UI Components enforcing their own conventions. The class must extend Column and implement prepareDataSource() - an ArgumentInterface ViewModel couldn't be used here. The pure formatting logic (number to star string) could still easily be extracted into a separate, testable utility method if it were needed in multiple places across the project.