Use this as a copyable starting point for `addons/example_notes/src/admin_runtime.php`.
It shows the expected admin boundary: the addon renders inner screen content, validates permissions and CSRF, saves addon-owned records, returns notices, and redirects after successful POST. The Admin shell owns the frame.
Required Inputs
This sample assumes the route passes:
- `$request`
- `$route`
- `$context`
The context must include:
- authenticated `authContext`
- `PDO` database connection
- shared admin/list helpers already loaded by the route/bootstrap
Copyable Skeleton
<?php
declare(strict_types=1);
if (!defined('HC_ACCESS')) {
exit;
}
function amv_example_notes_escape(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function amv_example_notes_admin_can(array $authContext, string $permission): bool
{
return function_exists('amv_core_permission_allows')
&& amv_core_permission_allows($authContext, $permission);
}
function amv_example_notes_admin_url(array $query = []): string
{
$query = array_merge(['view' => 'example_notes'], $query);
return '/admin/index.php?' . http_build_query($query);
}
function amv_example_notes_context_pdo(array $context): ?PDO
{
return ($context['pdo'] ?? null) instanceof PDO ? $context['pdo'] : null;
}
function amv_example_notes_auth_context(array $context): array
{
return is_array($context['authContext'] ?? null) ? $context['authContext'] : [];
}
function amv_example_notes_list(PDO $pdo): array
{
$statement = $pdo->query(
"SELECT note_uuid, title, slug, state_key, updated_at
FROM amv_example_notes
ORDER BY updated_at DESC
LIMIT 50"
);
return array_values(array_filter($statement->fetchAll() ?: [], 'is_array'));
}
function amv_example_notes_note(PDO $pdo, string $noteUuid): ?array
{
$statement = $pdo->prepare(
'SELECT note_uuid, title, slug, body_text, state_key
FROM amv_example_notes
WHERE note_uuid = :note_uuid
LIMIT 1'
);
$statement->execute(['note_uuid' => $noteUuid]);
$row = $statement->fetch();
return is_array($row) ? $row : null;
}
function amv_example_notes_save(PDO $pdo, array $payload): array
{
$noteUuid = trim((string) ($payload['note_uuid'] ?? ''));
$title = trim((string) ($payload['title'] ?? ''));
$body = trim((string) ($payload['body_text'] ?? ''));
$state = in_array((string) ($payload['state_key'] ?? 'draft'), ['draft', 'published', 'archived'], true)
? (string) $payload['state_key']
: 'draft';
if ($title === '') {
return ['ok' => false, 'reason' => 'title_required'];
}
$slug = function_exists('amv_core_slug_normalize')
? amv_core_slug_normalize((string) ($payload['slug'] ?? $title))
: strtolower(preg_replace('/[^a-z0-9]+/i', '-', $title) ?? '');
if ($slug === '') {
return ['ok' => false, 'reason' => 'slug_required'];
}
if ($noteUuid !== '') {
$statement = $pdo->prepare(
'UPDATE amv_example_notes
SET title = :title, slug = :slug, body_text = :body_text, state_key = :state_key, updated_at = NOW()
WHERE note_uuid = :note_uuid'
);
$statement->execute([
'title' => $title,
'slug' => $slug,
'body_text' => $body,
'state_key' => $state,
'note_uuid' => $noteUuid,
]);
return ['ok' => true, 'note_uuid' => $noteUuid];
}
$noteUuid = function_exists('amv_core_uuid_generate')
? amv_core_uuid_generate()
: 'replace-with-core-uuid';
$statement = $pdo->prepare(
'INSERT INTO amv_example_notes
(note_uuid, title, slug, body_text, state_key, created_at, updated_at)
VALUES
(:note_uuid, :title, :slug, :body_text, :state_key, NOW(), NOW())'
);
$statement->execute([
'note_uuid' => $noteUuid,
'title' => $title,
'slug' => $slug,
'body_text' => $body,
'state_key' => $state,
]);
return ['ok' => true, 'note_uuid' => $noteUuid];
}
function amv_example_notes_render_list(PDO $pdo, string $notice = ''): string
{
$rows = '';
foreach (amv_example_notes_list($pdo) as $note) {
$editUrl = amv_example_notes_admin_url([
'screen' => 'edit',
'note_uuid' => (string) ($note['note_uuid'] ?? ''),
]);
$rows .= '<tr>'
. '<td>' . amv_example_notes_escape($note['title'] ?? '') . '</td>'
. '<td>' . amv_example_notes_escape($note['slug'] ?? '') . '</td>'
. '<td>' . amv_example_notes_escape($note['state_key'] ?? '') . '</td>'
. '<td><a class="button secondary" href="' . amv_example_notes_escape($editUrl) . '">Edit</a></td>'
. '</tr>';
}
if ($rows === '') {
$rows = '<tr><td colspan="4">No notes found.</td></tr>';
}
$table = '<table><thead><tr><th>Title</th><th>Slug</th><th>State</th><th>Actions</th></tr></thead><tbody>'
. $rows
. '</tbody></table>';
return amv_admin_template_list_panel([
'eyebrow' => 'Addon',
'title' => 'Example Notes',
'intro' => 'Manage published and draft notes.',
'notice' => $notice !== '' ? '<p class="notice">' . amv_example_notes_escape($notice) . '</p>' : '',
'actionsHtml' => '<a class="button" href="' . amv_example_notes_escape(amv_example_notes_admin_url(['screen' => 'create'])) . '">Create Note</a>',
'table' => $table,
'listKey' => 'example-notes',
]);
}
function amv_example_notes_render_form(?array $note, string $notice = ''): string
{
$isEdit = is_array($note);
$noteUuid = $isEdit ? (string) ($note['note_uuid'] ?? '') : '';
$csrfAction = $isEdit ? 'example_notes.note.update.' . $noteUuid : 'example_notes.note.create';
$action = amv_example_notes_admin_url($isEdit
? ['screen' => 'edit', 'note_uuid' => $noteUuid]
: ['screen' => 'create']);
$form = '<form method="post" action="' . amv_example_notes_escape($action) . '">'
. '<input type="hidden" name="csrf_token" value="' . amv_example_notes_escape(amv_core_csrf_issue($csrfAction)) . '">'
. '<input type="hidden" name="note_uuid" value="' . amv_example_notes_escape($noteUuid) . '">'
. '<label>Title <input name="title" value="' . amv_example_notes_escape($note['title'] ?? '') . '" required></label>'
. '<label>Slug <input name="slug" value="' . amv_example_notes_escape($note['slug'] ?? '') . '"></label>'
. '<label>State <select name="state_key">'
. '<option value="draft">Draft</option><option value="published">Published</option><option value="archived">Archived</option>'
. '</select></label>'
. '<label>Body <textarea name="body_text" rows="10">' . amv_example_notes_escape($note['body_text'] ?? '') . '</textarea></label>'
. '<button type="submit">Save Note</button>'
. '</form>';
return amv_admin_template_form_panel([
'eyebrow' => 'Addon',
'title' => $isEdit ? 'Edit Note' : 'Create Note',
'intro' => 'Save addon-owned note content.',
'notice' => $notice !== '' ? '<p class="notice">' . amv_example_notes_escape($notice) . '</p>' : '',
'form' => $form,
]);
}
function amv_example_notes_handle_post(PDO $pdo, array $authContext, string $screen): array
{
if (!amv_example_notes_admin_can($authContext, 'example_notes.manage')) {
return ['notice' => 'Permission denied.', 'redirect' => ''];
}
$noteUuid = trim((string) ($_POST['note_uuid'] ?? ''));
$csrfAction = $noteUuid !== '' ? 'example_notes.note.update.' . $noteUuid : 'example_notes.note.create';
if (!amv_core_csrf_validate((string) ($_POST['csrf_token'] ?? ''), $csrfAction)) {
return ['notice' => 'Security token invalid. Reload and try again.', 'redirect' => ''];
}
$result = amv_example_notes_save($pdo, $_POST);
if (($result['ok'] ?? false) !== true) {
return ['notice' => 'Note could not be saved: ' . (string) ($result['reason'] ?? 'unknown'), 'redirect' => ''];
}
return [
'notice' => 'Note saved.',
'redirect' => amv_example_notes_admin_url([
'screen' => 'edit',
'note_uuid' => (string) ($result['note_uuid'] ?? ''),
'saved' => '1',
]),
];
}
function amv_example_notes_admin_route(array $request, array $route, array $context): array
{
$authContext = amv_example_notes_auth_context($context);
if (!amv_example_notes_admin_can($authContext, 'admin.access')
|| !amv_example_notes_admin_can($authContext, 'example_notes.view')) {
return amv_core_error_response('Permission denied.', 403);
}
$pdo = amv_example_notes_context_pdo($context);
if (!$pdo instanceof PDO) {
return amv_core_response_html('<h1>Database unavailable</h1>', 500);
}
$screen = (string) ($_GET['screen'] ?? $_POST['screen'] ?? 'index');
$notice = isset($_GET['saved']) ? 'Note saved.' : '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$postResult = amv_example_notes_handle_post($pdo, $authContext, $screen);
if ((string) ($postResult['redirect'] ?? '') !== '') {
return amv_core_response_redirect((string) $postResult['redirect']);
}
$notice = (string) ($postResult['notice'] ?? '');
}
if ($screen === 'create') {
return amv_core_response_html(amv_example_notes_render_form(null, $notice));
}
if ($screen === 'edit') {
$note = amv_example_notes_note($pdo, (string) ($_GET['note_uuid'] ?? $_POST['note_uuid'] ?? ''));
if ($note === null) {
return amv_core_response_html('<h1>Note not found</h1>', 404);
}
return amv_core_response_html(amv_example_notes_render_form($note, $notice));
}
return amv_core_response_html(amv_example_notes_render_list($pdo, $notice));
}
Adaptation Checklist
- Replace `example_notes` with your addon key.
- Replace table and field names with addon-owned names.
- Replace permission keys with registered permissions.
- Keep CSRF action names stable per form/action.
- Use `amv_core_csrf_issue()` when rendering forms.
- Use `amv_core_csrf_validate()` before writing.
- Use `amv_core_permission_allows()` or an addon-local wrapper around it.
- Redirect after successful POST.
- Keep admin URLs under `/admin/index.php?view={addon_key}` or the documented alternate admin route.
- Do not expose admin URLs as Menu Manager public targets.
Hidden-Knowledge Check
A developer should be able to copy this file, replace names, connect the schema and routes, and understand where permissions, CSRF, notices, and redirects belong.
Related: Developer Workflows/Admin Template Rules, Addon Development/Build Your First Addon, Addon Development/Permissions Contract Basics.