Skip to content

Table data source

The table field is stateless — rows come from a server handler each time the user paginates, searches, or filters. The framework dispatches via the {prefix}/table/{pageId}/{fieldId}/data filter; you return ['items', 'total'].

This recipe wires a $wpdb query to that hook.

[
'id' => 'orders',
'type' => 'table',
'label' => __('Orders', 'my-plugin'),
'args' => [
'fields' => [
['id' => 'id', 'label' => __('ID', 'my-plugin'), 'type' => 'text', 'sortable' => true],
['id' => 'customer', 'label' => __('Customer', 'my-plugin'), 'type' => 'text'],
['id' => 'total', 'label' => __('Total', 'my-plugin'), 'type' => 'text', 'sortable' => true],
['id' => 'status', 'label' => __('Status', 'my-plugin'), 'type' => 'text',
'filter' => [
'options' => [
'pending' => __('Pending', 'my-plugin'),
'completed' => __('Completed', 'my-plugin'),
'refunded' => __('Refunded', 'my-plugin'),
],
],
],
['id' => 'created', 'label' => __('Created', 'my-plugin'), 'type' => 'datetime', 'sortable' => true],
],
],
],

No callback keys, no PHP references in the config — pure data.

/**
* Fetch orders for the table field. Reads the framework's query array and
* returns `{ items, total }`.
*
* Hook: my-plugin/table/default/orders/data
*
* @param mixed $unhandled Sentinel.
* @param array $query {
* @type int $page 1-indexed.
* @type int $per_page Page size; negative means all rows (CSV export).
* @type bool $is_export True when called for CSV export.
* @type string $search Free-text search.
* @type string $orderby Column id.
* @type string $order 'asc' | 'desc'.
* @type array $filters Map of column id → filter value.
* }
* @return array{items: array, total: int}|WP_Error
*/
function my_plugin_table_orders(mixed $unhandled, array $query): array|WP_Error
{
global $wpdb;
$table = $wpdb->prefix . 'my_orders';
// ── Filters ────────────────────────────────────
$where = ['1 = 1'];
$params = [];
if ($query['search'] !== '') {
$where[] = '(id LIKE %s OR customer LIKE %s)';
$like = '%' . $wpdb->esc_like($query['search']) . '%';
$params[] = $like;
$params[] = $like;
}
if (! empty($query['filters']['status'])) {
$where[] = 'status = %s';
$params[] = (string) $query['filters']['status'];
}
$whereSql = implode(' AND ', $where);
// ── Sort ───────────────────────────────────────
$allowedSort = ['id', 'total', 'created'];
$orderby = in_array($query['orderby'], $allowedSort, true) ? $query['orderby'] : 'created';
$order = $query['order'] === 'desc' ? 'DESC' : 'ASC';
// ── Total (for pagination) ─────────────────────
$total = (int) $wpdb->get_var(
$wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$whereSql}", $params)
);
// ── Page of rows (all rows when exporting) ─────
$sql = "SELECT id, customer, total, status, created
FROM {$table}
WHERE {$whereSql}
ORDER BY {$orderby} {$order}";
if ($query['per_page'] >= 0) {
$offset = max(0, ($query['page'] - 1) * $query['per_page']);
$sql .= ' LIMIT %d OFFSET %d';
$params = array_merge($params, [(int) $query['per_page'], $offset]);
}
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return [
'items' => $rows ?: [],
'total' => $total,
];
}
add_filter('my-plugin/table/default/orders/data', 'my_plugin_table_orders', 10, 2);

Add actions to the config, then wire each one to its filter:

'actions' => [
['id' => 'mark_completed', 'label' => __('Mark completed', 'my-plugin'), 'supports_bulk' => true],
['id' => 'refund', 'label' => __('Refund', 'my-plugin'), 'destructive' => true,
'confirm' => __('Refund the selected orders?', 'my-plugin')],
],
add_filter(
'my-plugin/table/default/orders/mark_completed',
function ($unhandled, array $ids, WP_REST_Request $request): array {
global $wpdb;
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->prefix}my_orders SET status = 'completed' WHERE id IN ({$placeholders})",
array_map('intval', $ids)
)
);
return ['success' => true, 'updated' => count($ids)];
},
10, 3
);

The action filter receives the array of selected row IDs plus the request. Returning an array merges into the JSON response that the client surfaces in its snackbar.

To make a row openable in a chrome-less detail view, declare the action with opens_detail => true and wire the detail filters:

'actions' => [
['id' => 'view', 'label' => __('View', 'my-plugin'), 'opens_detail' => true],
],
'detail_view' => [
'back_label' => __('Back to all orders', 'my-plugin'),
],
// Load the entry.
add_filter('my-plugin/table/default/orders/detail/fetch', function ($_, $id, $request) {
global $wpdb;
return $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$wpdb->prefix}my_orders WHERE id = %d", (int) $id),
ARRAY_A
);
}, 10, 3);
// Render the HTML.
add_filter('my-plugin/table/default/orders/detail/render', function ($_, array $entry, $request): string {
ob_start();
?>
<h2><?php echo esc_html($entry['customer']); ?></h2>
<p><strong><?php esc_html_e('Total:', 'my-plugin'); ?></strong> <?php echo esc_html($entry['total']); ?></p>
<p><strong><?php esc_html_e('Status:', 'my-plugin'); ?></strong> <?php echo esc_html($entry['status']); ?></p>
<?php
return (string) ob_get_clean();
}, 10, 3);
// Title shown in the detail header.
add_filter('my-plugin/table/default/orders/detail/title', function ($_, array $entry, $request): string {
return sprintf(__('Order #%d', 'my-plugin'), (int) $entry['id']);
}, 10, 3);