A Moodle Admin List Page With Nothing but Core API

Every Moodle plugin eventually grows a custom table, and every custom table eventually needs an admin page: a list with a filter box, sortable columns, pagination, and the familiar triple-dot menu on each row. The good news is that the core “Browse list of users” look is achievable entirely with core API. No custom JavaScript, no Mustache template of your own, no CSS. 🎓 The running example is a fictional local_gear plugin that tracks equipment loaned to staff. One table:

1
2
3
4
5
6
7
local_gear_loan
  id          auto increment
  device      varchar        the device label, e.g. "MacBook Pro 14 #031"
  userid      -> mdl_user.id the borrower
  dueback     int            unix timestamp
  returned    int(1)         0/1
  usermodified, timecreated, timemodified

The finished page lives at Site administration > Equipment > Loans.

1. Register the page

Admin pages are declared in the plugin’s settings.php. A category gives you the “Equipment” grouping, an admin_externalpage gives you the menu entry, the breadcrumb, and the capability check in one line:

1
2
3
4
5
6
7
8
9
10
11
12
$ADMIN->add('root', new admin_category('local_gear_menu',
    get_string('pluginname', 'local_gear')));

$ADMIN->add(
    'local_gear_menu',
    new admin_externalpage(
        'local_gear_loans',
        get_string('loans', 'local_gear'),
        new \core\url('/local/gear/loans.php'),
        'local/gear:manageloans'
    )
);

Define the capability in db/access.php with ‘archetypes’ => []. Empty defaults mean nobody gets the page automatically except site administrators, who bypass capability checks entirely. Grant it to the intended role afterwards, or seed the grant in an upgrade step when the role is known. Two things you get for free and should not rebuild yourself: the page is findable in the admin search, and with $CFG->linkadmincategories = true every category in the breadcrumb becomes a link to its own landing page.

2. The page skeleton

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
require_once(__DIR__ . '/../../config.php');
require_once("{$CFG->libdir}/adminlib.php");

$page    = optional_param('page', 0, PARAM_INT);
$perpage = optional_param('per_page', 50, PARAM_INT);
$filter  = optional_param('device', '', PARAM_RAW_TRIMMED);
$sort    = optional_param('sort', '', PARAM_ALPHA);
$dir     = optional_param('dir', 'ASC', PARAM_ALPHA) === 'DESC' ? 'DESC' : 'ASC';

$url = new \core\url('/local/gear/loans.php');
if ($filter !== '') {
    $url->param('device', $filter);
}
if ($sort !== '') {
    $url->param('sort', $sort);
    $url->param('dir', $dir);
}
admin_externalpage_setup('local_gear_loans');

admin_externalpage_setup() does the login check, the capability check, the navigation, and the page heading. The $url object matters more than it looks: every link on the page (sort toggles, delete actions, the paging bar) is built from it, so the active filter and sort survive every click. 💡 Note what $dir does: anything that is not exactly DESC becomes ASC. That plus the whitelist in step 5 is the whole SQL injection story for sorting.

3. The filter, using core’s search template

Core ships the compact search box you see on the cohorts page as a Mustache template. Render it, then apply the value with the DML helpers, never by concatenating:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
echo $OUTPUT->render_from_template('core/search_input', [
    'action'       => (new \core\url('/local/gear/loans.php'))->out(false),
    'uniqid'       => 'gear-loan-search',
    'inputname'    => 'device',
    'searchstring' => get_string('searchbydevice', 'local_gear'),
    'query'        => $filter,
    'btnclass'     => 'btn-primary',
    'extraclasses' => 'mb-3',
]);

$where  = '1 = 1';
$params = [];
if ($filter !== '') {
    $where = $DB->sql_like('device', ':device', false);
    $params['device'] = '%' . $DB->sql_like_escape($filter) . '%';
}

The template’s action deliberately omits the query string. The form is a GET form and contributes only its own field, which also means a new search resets sorting and pagination. Core lists behave the same way.

4. The table, and the one property that will bite you

1
2
3
4
$table = new \core_table\output\html_table();
$table->responsive = false;
$table->head  = [ /* see step 5 */ ];
$table->align = ['left', 'left', 'left', 'left', 'left'];

$table->responsive = false is the line you will not find until you need it. By default html_writer::table() wraps the table in div.table-responsive, which carries overflow: auto. That container clips anything that pops out of it, and the row action menu from step 6 pops out of it. On the last row the menu opens downward and the bottom entries (usually Delete) are simply cut off and unreachable. It looks like a stacking or z-index problem. It is not. It is the overflow on the wrapper. If your table genuinely needs horizontal scrolling on narrow screens, keep the wrapper and live without dropdowns in rows. For an admin-only list with a handful of columns, dropping the wrapper is the right trade.

5. Sortable column headers

Core’s users page pattern is a link per header that toggles direction, with an arrow icon on the active column. A small helper in lib.php covers every list page in the plugin:

1
2
3
4
5
6
7
8
9
10
11
12
13
function local_gear_sortable_heading(\core\url $pageurl, string $column,
        string $label, string $activesort, string $activedir): string {
    global $OUTPUT;

    $newdir = ($activesort === $column && $activedir === 'ASC') ? 'DESC' : 'ASC';
    $heading = html_writer::link(
        new \core\url($pageurl, ['sort' => $column, 'dir' => $newdir]), $label);
    if ($activesort === $column) {
        $heading .= ' ' . $OUTPUT->pix_icon(
            $activedir === 'ASC' ? 't/sort_asc' : 't/sort_desc', '');
    }
    return $heading;
}

The ORDER BY is built from a whitelist. The user-supplied $sort value never reaches the SQL, only the array key lookup does:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$sortcolumns = [
    'device'   => 'l.device',
    'borrower' => 'u.lastname',
    'returned' => 'l.returned',
];
$orderby = isset($sortcolumns[$sort])
    ? "{$sortcolumns[$sort]} {$dir}, l.id ASC"
    : 'l.id ASC';

$fromjoins = " FROM {local_gear_loan} l
          LEFT JOIN {user} u ON u.id = l.userid"
;

$total = $DB->count_records_sql("SELECT COUNT(*)" . $fromjoins . " WHERE {$where}", $params);
$loans = $DB->get_records_sql("SELECT l.*, u.firstname, u.lastname"
    . $fromjoins . " WHERE {$where} ORDER BY {$orderby}",
    $params, $page * $perpage, $perpage);

Three details worth copying. The l.id ASC tie-breaker makes the order deterministic, so two rows with the same device name never swap places between page loads. The fallback default is plain creation order, which reads as “oldest first” and surprises nobody. And it is a LEFT JOIN, not a JOIN: if a borrower account was deleted, the loan row should still be listed with a placeholder name rather than silently vanish.

6. The kebab action menu

1
2
3
4
5
6
7
8
9
10
function local_gear_action_menu(\core_renderer $output,
        \core\url $editurl, \core\url $deleteurl): string {
    $menu = new \core\output\action_menu();
    $menu->set_kebab_trigger(null, $output);
    $menu->add(new \core\output\action_menu\link_secondary(
        $editurl, new \core\output\pix_icon('t/edit', ''), get_string('edit')));
    $menu->add(new \core\output\action_menu\link_secondary(
        $deleteurl, new \core\output\pix_icon('t/delete', ''), get_string('delete')));
    return $output->render($menu);
}

Each row’s last cell is one call to this helper. Remember step 4: this component is why the responsive wrapper had to go.

7. Delete, with confirmation

The delete link points back at the same page with action=delete&id=N. First hit renders a confirmation, the confirmed POST does the work:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if ($action === 'delete') {
    $loan = $DB->get_record('local_gear_loan',
        ['id' => required_param('id', PARAM_INT)], '*', MUST_EXIST);

    if (!optional_param('confirm', false, PARAM_BOOL)) {
        echo $OUTPUT->header();
        echo $OUTPUT->confirm(
            get_string('deleteloanconfirm', 'local_gear', s($loan->device)),
            new core\output\single_button(
                new \core\url($url, ['action' => 'delete', 'id' => $loan->id, 'confirm' => true]),
                get_string('delete'), 'post'),
            $url
        );
        echo $OUTPUT->footer();
        exit;
    }

    require_sesskey();
    $DB->delete_records('local_gear_loan', ['id' => $loan->id]);
    redirect($url, get_string('deleted', 'local_gear'), null,
        \core\output\notification::NOTIFY_SUCCESS);
}

single_button with post carries the sesskey for you, and require_sesskey() on the confirmed branch enforces it. Name the record in the confirmation text. “Delete loan for MacBook Pro 14 #031?” is checkable, “Are you sure?” is not. If deleting the row must also clean up child rows, wrap the deletes in $DB->start_delegated_transaction() so a failure cannot leave half the cleanup done.

8. Pagination

1
2
echo html_writer::table($table);
echo $OUTPUT->paging_bar($total, $page, $perpage, $url);

That is the entire feature, because the fetch in step 5 already took limit and offset arguments, and because $url already carries the filter and sort. The bar renders nothing when everything fits on one page. One planning note: paging_bar() accepts a custom parameter name, so two paginated tables can coexist on one page. Having built that once, I recommend not doing it. Give each list its own admin page with its own menu entry. The pages stay simple, the standard page parameter works everywhere, admin search finds each list by name, and cross-linking two pages is one secondary button per side.

9. Counts without an N+1

If a column shows a count from another table, the borrower’s number of active loans for instance, resist calling count_records() inside the row loop. One grouped query before the loop serves every row:

1
2
3
$loancounts = $DB->get_records_sql_menu(
    "SELECT userid, COUNT(*) FROM {local_gear_loan}
      WHERE returned = 0 GROUP BY userid"
);

Then each row reads $loancounts[$loan->userid] ?? 0. Fifty rows, one query.

The checklist

  1. admin_category + admin_externalpage, capability with empty archetypes.
  2. admin_externalpage_setup(), params cleaned, one $url carrying filter and sort.
  3. core/search_input template for the filter, sql_like + sql_like_escape in the query.
  4. html_table with responsive = false when rows have dropdowns.
  5. Whitelisted ORDER BY, header links that toggle direction, id as tie-breaker.
  6. action_menu with set_kebab_trigger() for row actions.
  7. Confirm page + require_sesskey() + transaction for destructive actions.
  8. paging_bar() fed by the same $url.
  9. Grouped queries for per-row counts.

Every piece is core API, so the page inherits theme styling, localization, and accessibility without any custom CSS or JavaScript. 🐘

This entry was posted in Moodle, PHP and tagged , . Bookmark the permalink.

Comments are closed.