PHP / Magento Dev Blog

  • Home

Custom Admin Grid in Magento 2 with UI Component

by Henryk Tews / Friday, 16 October 2026 / Published in Magento / Adobe Commerce

A custom admin grid in Magento 2 is a recurring task for any substantial module. Magento provides a full UI Component framework for it: declarative XML defines columns, filters, and actions, while PHP only supplies the DataProvider. Below is a minimal but complete example – from database migration through model to listing.xml.

Module structure

app/code/Vendor/Module/
├── etc/
│   ├── module.xml
│   ├── db_schema.xml
│   └── adminhtml/
│       ├── routes.xml
│       └── menu.xml
├── Model/
│   ├── Queue.php
│   └── ResourceModel/
│       ├── Queue.php
│       └── Queue/
│           └── Collection.php
├── Ui/
│   └── DataProvider/
│       └── Queue/
│           └── ListingDataProvider.php
├── Controller/
│   └── Adminhtml/
│       └── Queue/
│           ├── Index.php
│           └── Delete.php
└── view/
    └── adminhtml/
        ├── layout/
        │   └── vendor_module_queue_index.xml
        └── ui_component/
            └── vendor_module_queue_listing.xml

Database schema

<!-- etc/db_schema.xml -->
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="vendor_module_queue" resource="default" engine="innodb"
           comment="Module Queue">
        <column xsi:type="int" name="queue_id" unsigned="true" nullable="false"
                identity="true" comment="Queue ID"/>
        <column xsi:type="varchar" name="status" nullable="false" length="32"
                default="pending" comment="Status"/>
        <column xsi:type="varchar" name="job_type" nullable="false" length="128"
                comment="Job Type"/>
        <column xsi:type="text" name="payload" nullable="true" comment="Payload JSON"/>
        <column xsi:type="int" name="attempts" nullable="false" default="0"
                comment="Retry attempts"/>
        <column xsi:type="timestamp" name="created_at" nullable="false"
                default="CURRENT_TIMESTAMP" comment="Created At"/>
        <column xsi:type="timestamp" name="updated_at" nullable="false"
                default="CURRENT_TIMESTAMP" on_update="true" comment="Updated At"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="queue_id"/>
        </constraint>
        <index referenceId="VENDOR_MODULE_QUEUE_STATUS" indexType="btree">
            <column name="status"/>
        </index>
    </table>
</schema>

Model, ResourceModel and Collection

<?php

// Model/Queue.php
namespace Vendor\Module\Model;

use Magento\Framework\Model\AbstractModel;

class Queue extends AbstractModel
{
    protected function _construct(): void
    {
        $this->_init(ResourceModel\Queue::class);
    }
}

// Model/ResourceModel/Queue.php
namespace Vendor\Module\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\AbstractDb;

class Queue extends AbstractDb
{
    protected function _construct(): void
    {
        $this->_init('vendor_module_queue', 'queue_id');
    }
}

// Model/ResourceModel/Queue/Collection.php
namespace Vendor\Module\Model\ResourceModel\Queue;

use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;

class Collection extends AbstractCollection
{
    protected function _construct(): void
    {
        $this->_init(\Vendor\Module\Model\Queue::class, \Vendor\Module\Model\ResourceModel\Queue::class);
    }
}

DataProvider

<?php

namespace Vendor\Module\Ui\DataProvider\Queue;

use Magento\Ui\DataProvider\AbstractDataProvider;
use Vendor\Module\Model\ResourceModel\Queue\CollectionFactory;

class ListingDataProvider extends AbstractDataProvider
{
    public function __construct(
        string $name,
        string $primaryFieldName,
        string $requestFieldName,
        CollectionFactory $collectionFactory,
        array $meta = [],
        array $data = []
    ) {
        parent::__construct($name, $primaryFieldName, $requestFieldName, $meta, $data);
        $this->collection = $collectionFactory->create();
    }

    public function getData(): array
    {
        if (!$this->getCollection()->isLoaded()) {
            $this->getCollection()->load();
        }

        $items = $this->getCollection()->toArray();

        return [
            'totalRecords' => $this->getCollection()->getSize(),
            'items'        => array_values($items['items'] ?? $items),
        ];
    }
}

UI Component listing.xml (condensed)

The listing XML declares the data source pointing to the DataProvider, toolbar with filters and paging, and column definitions. Date columns use Magento\Ui\Component\Listing\Columns\Date, select columns reference a Source model that returns option arrays, and the actions column uses a custom Column class that builds edit/delete URLs via UrlBuilder.

Actions column

<?php

namespace Vendor\Module\Ui\Component\Listing\Column;

use Magento\Framework\UrlInterface;
use Magento\Framework\View\Element\UiComponent\ContextInterface;
use Magento\Framework\View\Element\UiComponentFactory;
use Magento\Ui\Component\Listing\Columns\Column;

class QueueActions extends Column
{
    private const URL_PATH_DELETE = 'vendor_module/queue/delete';
    private const URL_PATH_EDIT   = 'vendor_module/queue/edit';

    public function __construct(
        ContextInterface $context,
        UiComponentFactory $uiComponentFactory,
        private readonly UrlInterface $urlBuilder,
        array $components = [],
        array $data = []
    ) {
        parent::__construct($context, $uiComponentFactory, $components, $data);
    }

    public function prepareDataSource(array $dataSource): array
    {
        if (!isset($dataSource['data']['items'])) {
            return $dataSource;
        }

        foreach ($dataSource['data']['items'] as &$item) {
            $name = $this->getData('name');
            if (!isset($item['queue_id'])) continue;

            $item[$name] = [
                'edit' => [
                    'href'  => $this->urlBuilder->getUrl(self::URL_PATH_EDIT, ['queue_id' => $item['queue_id']]),
                    'label' => __('Edit'),
                ],
                'delete' => [
                    'href'    => $this->urlBuilder->getUrl(self::URL_PATH_DELETE, ['queue_id' => $item['queue_id']]),
                    'label'   => __('Delete'),
                    'confirm' => [
                        'title'   => __('Delete Job'),
                        'message' => __('Are you sure you want to delete this job?'),
                    ],
                    'post' => true,
                ],
            ];
        }

        return $dataSource;
    }
}

After generating the module: bin/magento setup:upgrade creates the table, bin/magento cache:flush refreshes UI Components. The grid appears in admin once menu.xml and routes.xml are configured. Mass actions (delete selected) are added via a massaction element in listingToolbar and a separate MassDelete controller extending AbstractMassAction.

About Henryk Tews

What you can read next

Xdebug – configuration, PHPStorm, debugging Magento plugins
Strategy pattern in PHP – and how Magento 2 uses it in pricing

© 2026 Created by

TOP
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Always active
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}