Developer docs
Start
Build with AI
Themes
Apps
- App development
- App development best practices
- Hooks and filters
- The App API
- Sending email from apps
- Admin bar for apps and themes
- Public routes for apps
- Admin screens, routes and UI components
- Dashboard widgets
- Data tables
- AJAX actions
- Media and uploads for apps
- App permissions
- Content types, blocks and widgets
- Content types, taxonomies and custom fields
- Search
- When an app fails: fault isolation and recovery
API and AI agents
Security and contributing
Data tables
Basehim's admin list screens (Posts, Pages, Comments, Apps) share one data table system. A core service renders a consistent table, and an app can add a column to an existing table without replacing it.
The table gives every list the same features: search, filters, sortable columns, server-side pagination, row selection and bulk actions, columns users can show, hide and reorder, density, and desktop and mobile layouts. It is built on pieces that already exist: one query per feature (paginate()), the AJAX gateway, capability and area checks, and the users.meta column for per-user preferences. There is no second router and no new storage.
// In your app's boot(): add an "SEO Score" column to the Posts table.
$this->addFilter('datatable.columns.content.posts', function (array $cols): array {
$cols[] = [
'id' => 'my-seo.score', // namespaced by your slug
'label' => 'SEO Score',
'prepare' => [$this, 'scores'], // one bulk query for the whole page
'render' => [self::class, 'badge'],
'capability' => 'edit_posts', // hidden from users without it
'default_visible' => false, // opt-in
];
return $cols;
});
The parts
| Part | |
|---|---|
| Table | A registered screen, keyed namespace.name (content.posts). Core registers its tables in routes/tables.php. |
| Column | How a cell value is produced, and whether the column can be sorted, filtered, hidden or reordered. |
| Provider | A thin adapter that maps the generic request onto a feature's existing paginate(). Core owns the SQL; the table service never does. |
| Preferences | Per-user column visibility, order, page size and density, in users.meta. |
Core's tables are content.posts, content.pages and comments.list, and since 1.2.85 apps.installed (the Apps screen).
Adding a column to a core table
From an app, in boot(), add a filter named datatable.columns. plus the table key. It receives the current list of column arrays; return it with yours appended. Themes can't do this, because they have no PHP lifecycle to register one.
public function boot(): void
{
$this->addFilter('datatable.columns.content.posts', [$this, 'addColumn']);
}
public function addColumn(array $cols): array
{
$cols[] = [
'id' => 'my-app.reading_time', // must be namespaced by your slug
'label' => 'Reading time',
'render' => [self::class, 'render'],
'sortable' => false,
'default_visible' => false,
'can_hide' => true,
'capability' => 'edit_posts',
'responsive' => 'desktop-only',
'align' => 'center',
];
return $cols;
}
A column definition
| Key | ||
|---|---|---|
id | required | Unique within the table and namespaced by your slug (my-app.x). A duplicate is dropped and logged. |
label | required | Column heading. |
value | one of value/render | A row key. Core reads $row[value] and HTML-escapes it. Use for plain fields. |
render | one of value/render | callable(array $row, mixed $prepared): string returning HTML. A string such as "Class::method" is refused; pass a real callable. App render output is sanitized (scripts, forms and iframes removed); always escape the data you interpolate. |
prepare | optional | callable(array $rows): mixed, run once for the whole page before any cell is rendered. Do one bulk query here and return a lookup; core hands it to every render() as $prepared. See Performance. |
sortable | default false | A list of sort tokens this column enables, e.g. ['title_az', 'title_za']. The tokens must also be ones the provider accepts. |
filter | optional | ['field' => 'status', 'in' => ['draft', 'published']]. The field must be one the provider accepts; a request value outside in is ignored. Add 'labels' to name the options. |
default_visible | default true | Whether it shows before the user chooses. |
can_hide | default true | false keeps it always visible (the title, for example). |
capability | optional | A user without it never receives the column: it is removed from the response, not hidden with CSS. |
responsive | default always | always, tablet-up or desktop-only. A layout hint, separate from the user's own choices, so a narrow screen never overwrites desktop column settings. |
align | default left | left, center or right. |
A column is sortable or filterable only if it says so.
Performance: the two-phase contract
A page of 100 rows and 12 columns is up to 1,200 cells. To keep the cost proportional to the number of columns, never rows × columns:
prepare(array $rows)runs once per column for the whole page. Do a singleWHERE id IN (…)query and return a lookup keyed by row id.render(array $row, $prepared)must be pure: read the row and$preparedonly. No database, no HTTP, no filesystem.
public function scores(array $rows): array // ONE query for the page
{
$ids = array_map(fn($r) => (int) $r['id'], $rows);
if (!$ids) return [];
$in = implode(',', array_map('intval', $ids)); // ints only: safe to inline
$map = [];
foreach ($this->db()->select("SELECT post_id, meta_value FROM {post_meta}
WHERE meta_key = 'seo_score' AND post_id IN ($in)") as $r) {
$map[(int) $r['post_id']] = (int) $r['meta_value'];
}
return $map;
}
public static function badge(array $row, $prepared): string // pure
{
$score = (int) ($prepared[(int) $row['id']] ?? 0);
return '<span class="bh-dt-badge">' . htmlspecialchars((string) $score, ENT_QUOTES) . '</span>';
}
Core also caps per_page and wraps every prepare() and render() in a guard: a column that throws costs that column, never the page. Columns whose data the provider's query already joins (author, featured image) need no prepare. Remember that db() needs the db.raw permission if your app declares permissions.
The AJAX lifecycle
Reads go through one action, content.table. It is a GET, so it isn't CSRF-checked and must stay free of side effects. The action is registered with capability read; the real gate is the table's own capability and area, enforced in the handler, so a site's admin.area_policy overrides apply and the action is never more open than the screen.
Basehim.ajax.get('content.table', { table, page, per_page, sort, search,
filters: JSON, columns: JSON })
→ gateway (auth, rate limit, input schema)
→ table capability + area
→ columns for this user (core + app columns, capability-gated)
→ provider->query(TableQuery) → the feature's paginate() (bound params, sort whitelist)
→ prepare() per column, then pure render() per cell, escaped and sanitized
→ { rows, meta, columns, catalog, prefs }
The request is validated before a provider sees it: sort must be one the provider declared, every filter key must be one it declared (values checked against any in set), and per_page must be an allowed page size. Anything else is dropped or refused with 422.
Writes (bulk publish or delete, row edits) are not a new generic endpoint. They reuse each feature's existing CSRF-protected routes (such as /admin/posts/bulk), so the ownership and publishing rules that already live there are never bypassed. The client collects the selected ids and submits that form.
Preferences
Per-user column visibility, order, page size and density are stored in the users.meta JSON column under datatable, by table key, and saved with content.table_prefs (reset with content.table_prefs_reset).
Every read is reconciled with the current table definition and the columns the user may see: unknown ids are dropped, a hidden column that can't be hidden is brought back, visibility is limited to permitted columns, the page size is clamped to the allowed set, and a sort no column declares is rejected. A preference saved before a column was removed or a permission changed can never widen access or break the table.
Registering a table
Apps usually extend core's tables rather than register their own, but the same registration works for an app's own admin list:
$tables->register('content.posts', [
'label' => 'Posts',
'capability' => 'edit_posts',
'area' => '/admin/posts',
'provider' => \App\Services\Table\PostTableProvider::class,
'default_sort' => 'newest',
'page_sizes' => [8, 20, 50, 100],
'columns' => [ /* column arrays, as above */ ],
'row_actions' => ['edit', 'trash'],
'bulk_actions' => ['publish', 'draft', 'delete'],
]);
Declared actions (1.2.85)
Instead of the built-in bulk_actions ids (publish, draft, approve and so on), a table can declare its own actions. They build the row menu and the bulk bar:
'actions' => [
['id' => 'activate', 'label' => 'Activate', 'icon' => 'bolt', 'tone' => 'publish', 'when' => ['inactive']],
['id' => 'permissions', 'label' => 'Permissions', 'icon' => 'shield-check', 'flag' => 'permissions', 'bulk' => false, 'event' => true],
['id' => 'logs', 'label' => 'Logs', 'icon' => 'document-text', 'href' => '/admin/apps/{key}/logs'],
['id' => 'delete', 'label' => 'Delete', 'icon' => 'trash', 'danger' => true, 'confirm' => 'Delete {n} app{s}?'],
],
| Key | |
|---|---|
when | Row statuses ($row['status']) the action applies to; empty means all. The bulk bar shows it when any selected row matches, and runs it on those rows. |
flag | A name the row must carry in $row['row_flags'] (row menu only). |
href | A root-relative link; {id} and {key} ($row['row_key']) are filled in. Not a bulk action. |
event | Emit bh:dt-action {table, action, id, key, row} on the table instead of running it; the page handles it. |
confirm | Ask first; {n} is the count, {s} an "s" when it isn't one. |
tone | publish, neutral or danger, for the bulk button. |
bulk / row | Whether it appears in the bulk bar and the row menu (both by default; an href is row-only). |
Everything else runs through the bh:dt-bulk event or the bulkEndpoint form, as before.
Providers
A provider implements three methods and owns no SQL of its own:
final class PostTableProvider implements \App\Core\Table\TableProvider
{
public function __construct(private \App\Services\PostService $posts) {}
public function sortKeys(): array { return ['newest', 'oldest', 'title_az', 'title_za']; }
public function filterKeys(): array { return ['status', 'author_id', 'trashed']; }
public function query(\App\Core\Table\TableQuery $q): array {
return $this->posts->paginate([ /* map $q onto the existing filters */ ], $q->page, $q->perPage);
}
}
query() returns Basehim's usual shape unchanged: ['data' => rows, 'meta' => ['page', 'per_page', 'total', 'last_page']].
Security notes
- A hidden column is not a boundary. Capability-gated columns are removed from the JSON, and per-row ownership is still the handler's job: the table lists what the screen lists, and acting on a row re-checks ownership on submit.
- Sort and filter inputs are whitelists, so no request string reaches an
ORDER BYor a column it wasn't declared for. - App column HTML is untrusted: it is sanitized, and you must still escape values.
per_pagehas a hard ceiling;prepare()only ever sees one page of rows.
The front end
admin/assets/css/datatable.css and admin/assets/js/datatable.js draw the table with the admin's bh-* components, through Basehim.ajax. A screen includes the shared partial:
<?php $this->include('partials.datatable', [
'tableKey' => 'content.posts',
'bulkEndpoint' => $base . '/admin/posts/bulk',
'editBase' => $base . '/admin/posts/{id}/edit',
]); ?>
From an app (1.3.0), all of these work: core's View::include() as above, $this->make(\App\Core\View::class)->include('partials.datatable', [...]), or setting $tableKey (and the rest) and require BASEHIM_ROOT . '/admin/views/partials/datatable.php' inside your own view. The partial asks for datatable.css with \App\Core\Http\HeadStyles::add(): inside $this->adminView() it goes in the page's <head>, and in a bare $this->view() fragment it is printed in place. Never rely on $this inside a core partial: an app may be including it.
The partial renders the first page on the server, so the table works without JavaScript, and embeds the configuration the script enhances. The script builds the table as soon as it runs; until then the server's markup keeps its place but isn't shown (only when scripts run, and shown after 2.5 seconds regardless), so the plain table never flashes before the finished one (1.3.0). Posts and Pages use it by default; ?view=classic falls back to the original server-rendered table. The CSS and JavaScript are versioned by their modification time, so browsers pick up a changed file immediately.
Reusable cell components
App\Core\Admin\Components renders things that recur across admin screens, so a column or any other view shows them the same way:
use App\Core\Admin\Components;
Components::avatar($name, $url, 28); // image, or initials on a colour
Components::userCell($name, $url, $sub, 28); // avatar + name (+ optional sub-line)
Components::thumb($featuredUrl, $alt, 40); // media thumbnail, or a placeholder
Components::statusPill($status, $tones); // a coloured status pill
They are also available as the global functions bh_avatar_html(), bh_user_cell() and bh_thumb() for use directly in a view. The Posts table's thumbnail and author columns are built with them:
['id' => 'thumb', 'label' => 'Image', 'default_visible' => true, 'can_hide' => true,
'render' => fn (array $r) => Components::thumb($r['featured_url'] ?? null, (string) ($r['featured_alt'] ?? ''), 40)],
['id' => 'author', 'label' => 'Author',
'render' => fn (array $r) => Components::userCell((string) ($r['author_name'] ?? '—'), $r['author_avatar_url'] ?? null)],
Leave a comment