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.
