Admin presenter#
An administration screen: a listing in a DataGrid, with adding and
editing through a form underneath. The groundwork is done by
app/Core/Base/Admin/BasePresenter.php — this chapter is about what you have to write
on top of it.
What the base presenter does for you#
startup() runs in this order and you get all of it for free:
| Step | What it does |
|---|---|
| turning off the query cache | the whole admin request reads from the database, not from Redis |
| contexts | settings, theme, localisation, language, translator |
checkAuthentication() |
JWT ↔ Nette session synchronisation |
authorize() |
RBAC — the default requirement is the :Admin privilege |
loadUserPrivileges() |
fills in the permissions for isAllowed() in templates and grids |
authorizeListing() |
for the show action requires the …:show key, see Read and write gates |
initializePage() |
looks up the AdminPage by the presenter + action pair |
On top of that: createForm() (a form with buttons and a way back), the translator in
the template, getAdminItemsPerPage() and the Picker mode.
An own key in authorize() rejects even the administrator
loadUserPrivileges() runs only after authorize(). Inside authorize() the privilege
array is still empty and isAllowed() fails closed, so checkPrivilege() there returns 403 to
everyone. A presenter that puts its gate into authorize() already (a screen without a show
action, a whole section under one key) loads the privileges itself:
protected function authorize(): void
{
parent::authorize(); // `:Admin`
$this->loadUserPrivileges(); // startup() does it only after us
$this->checkPrivilege(self::PRIVILEGE);
}
The second call from startup() is served from the PrivilegeManager cache (one per
request) and does not touch the database. Examples: CustomAssetPresenter,
ThemeSettingPresenter.
Theme and language are set up before checkAuthentication(), so they also run on the
anonymous login page. Whatever they read must be available without an identity, see
a dedicated settings screen.
The admin request has the cache off on purpose — do not turn it back on
What is switched off is the context of the whole request, not just the admin repository: front managers run inside the administration too (assets, theme, user) and those go through a repository that allows caching. Without this, a cached record could reach the form — and an admin form is a read followed by a write. Something other than what the administrator saw would get saved.
In the administration the translation domain is NOT derived from the namespace
Unlike a Front presenter, you have to declare it by hand:
protected string $translatorDomain = 'admin.blog.presenters.tag';. Without it
nothing in the template is translated and createForm() skips
useDomainTranslator() — the form texts then stay as bare keys. In detail:
Translations.
What a finished presenter looks like#
The presenter file holds only data, actions and the grid factory. The form lives in a trait.
final class TagPresenter extends BasePresenter
{
use TagFormTrait; // createComponentEditForm + saving
use AdminFormHelpersTrait; // getFormLanguages() + str()
use AdminEditFormComponentsTrait; // language tabs + buttons
protected string $translatorDomain = 'admin.blog.presenters.tag';
private ?Tag $editedTag = null;
public function __construct(
private readonly DataGridFactory $dataGridFactory,
private readonly TagManager $tagManager,
) {
parent::__construct();
}
public function actionEdit(int $id): void
{
$tag = $this->tagManager->findAll(
filters: [new EqualFilter('id', $id)],
limit: 1,
includes: ['translations'],
)->first();
$this->editedTag = $tag instanceof Tag ? $tag : null;
if ($this->editedTag === null) {
$this->error('Tag not found.');
}
}
public function renderAdd(): void
{
$this->template->pageTitle = 'form.headers.add';
$this->template->languages = $this->getFormLanguages();
$this->setView('edit'); // add and edit share one template
}
}
| Action | Template | What it does |
|---|---|---|
show |
Templates/<Presenter>/show.latte |
the listing, just {control grid} |
add |
shares edit.latte via setView('edit') |
an empty form |
edit |
Templates/<Presenter>/edit.latte |
a form with default values |
Procedure: creating a new admin presenter#
- The model has to exist first. The Admin variant of the entity, the manager, service, mapper and repository — see Code conventions, the section on entities and managers.
- The presenter into
app/UI/Admin/<Module>/Presenters/XxxPresenter.php:final,extends BasePresenter,$translatorDomain, a private property for the edited record, a constructor takingDataGridFactoryand the manager. - The actions:
actionEdit(int $id)loads into the property and calls$this->error()when nothing is found;actionAdd()nulls the property;renderAdd()/renderEdit()setpageTitleand the languages.renderAdd()ends withsetView('edit'). - The grid in
createComponentGrid()— the data source is the manager, not a query. Do not forgetsetIncludes()for whatever the grid actually shows, andsetItemsPerPage($this->getAdminItemsPerPage()). - The form into its own trait
Presenters/Traits/Forms/XxxFormTrait.php(procedure below). - The templates
Templates/<Presenter>/show.latteandedit.latte. - A row in
cms_admin_system_pageswith thepresenter(with the leading colon,:Admin:Blog:Tag) andactionpair, plus translations incms_admin_system_page_texts. - A menu item and its link to the page
(
cms_admin_system_page_menu_item_relations). - The translation keys into
app/Locale/<module>/<locale>/admin.<LOCALE>.neonunderadmin.<module>.presenters.<presenter>— in every language right away. - An RBAC key for actions not everyone should see; conditional display in the grid
through
isAllowed(). - Click through: listing, filter, sorting, pagination, adding, editing, deleting and the way back via the "Cancel" button.
Without a row in cms_admin_system_pages the page has no title and no menu
initializePage() looks the page up by the exact presenter + action pair, where
the presenter column holds the full Nette name with a leading colon. When the
row is missing nothing crashes — the title and meta tags just stay empty and no menu
item is highlighted. It looks like a template bug.
A pretty admin URL needs the page translation, otherwise it falls back to the generic route
AdminRouteFactory tries the pretty form admin/<presenterHref>/<actionHref> first
and returns null without a stored translation — the
admin/<module>/<presenter>/<action> fallback is used instead. The address works,
it is just ugly; nothing reports it.
Procedure: adding a form#
The skeleton is done by createForm(); you supply the fields and a callback that saves.
createComponentEditForm()in theFormTraitpassescreateForm()a callback returningbool— success or not:
protected function createComponentEditForm(): Form
{
$form = $this->createForm(fn(ArrayHash $values): bool => $this->saveTagForm($values));
$this->addTranslationFields($form);
if ($this->getHttpRequest()->isMethod('GET') && $this->getSignal() === null) {
$this->setTagFormDefaults($form);
}
return $form;
}
- Fill the defaults only on a plain GET render. The condition above is deliberate — after a submit or during a signal the defaults would overwrite what the user typed.
- The callback saves through the manager, evaluates
isSuccess(), sends a flash and on success sets$this->savedEntityId:
private function saveTagForm(ArrayHash $values): bool
{
$tag = $this->buildTagEntity($values);
$result = $this->tagManager->save($tag);
if (!$result->isSuccess()) {
$this->flashMessage($this->translatorDomain . '.form.messages.save.error', 'danger');
return false;
}
$this->flashMessage($this->translatorDomain . '.form.messages.save.success', 'success');
$this->savedEntityId = $tag->getId();
return true;
}
- The buttons and redirects are already handled:
| Button | Behaviour |
|---|---|
Save (save) |
saves and returns to the page you came from |
Apply (update) |
saves and stays on the edit screen |
Cancel (cancel) |
just a link back, saves nothing |
- The way back is per form. The address of the previous page is taken from
Refereron the first GET render, stored in the session under a random key, and the key goes into the hiddenbackKeyfield. Concurrent tabs therefore do not overwrite each other's return path.
An unset $savedEntityId means a DUPLICATE on the second “Apply”
On the add action update leads to redirect('this') — that is, back to an
empty add form. A second click on "Apply" creates another new record there. The
base class handles it by redirecting to edit?id=…, but only when the callback
fills $savedEntityId in.
Only save and update save
Nette fires onSuccess for every valid submit. The base class therefore checks
the name of the clicked button internally and returns without saving for the others.
When you add your own submit (a Picker redraw, "add a row"), it will not save — and
that is correct. Anyone not expecting it goes looking for why the data was not
stored.
A custom button that redirects from add to edit must carry backKey
Without it the edit generates a new key from the Referer, that is the add page, and both
"Save" and "Cancel" on the edit return the admin to the add page. If the button is AJAX,
give it data-naja-force-redirect: naja then fully loads the edit instead of swapping
snippets, which fails between the add and edit layouts. Pattern:
ProductFormTrait::saveProductAndManageVariants().
Procedure: adding multilingual fields#
AdminFormHelpersTraitgives yougetFormLanguages()andstr();AdminEditFormComponentsTraitthe language tabs and the button bar.- One set of fields per language, with a
_<langId>suffix:
foreach ($this->getFormLanguages() as $language) {
$langId = (int) $language->getId();
$active = $form->addCheckbox('active_' . $langId, 'form.fields.active.label');
$form->addText('name_' . $langId, 'form.fields.name.label')
->setNullable()
->addConditionOn($active, $form::EQUAL, true)
->setRequired($t('form.fields.name.required'));
}
- When building the entity look the translation up through
getTranslation($langId)and create the missing ones includingsetParent(). includes: ['translations']belongs in the load insideactionEdit()— without it the form offers nothing.- In the template render the language bar
{control adminLangNav $languages, $form}and below it one panel per language:id="lang-panel-{$lid}",aria-labelledby="lang-tab-{$lid}", the first panelshow active. The bar also renders theactive_<langId>checkbox in the language pill, so do not render it again in the panel.
adminLangNav parameter |
When to pass it |
|---|---|
third, $idPrefix ('set-', 'tariff-') |
the page has more than one language bar (form + component or modal); that section's panels then use id="{$prefix}lang-panel-…" |
fourth, $withActive = false |
the form has no "active translation" (language variants of settings, site identity); the bar carries tabs only and no active_<langId> field is needed |
Two bars without a prefix switch each other's panels
Both produce lang-tab-<id> and lang-panel-<id>, so clicking a language in one section
switches the panel in the other and the fields of that language do not show in the first.
The bar and the panels must use the same prefix.
A required field in a hidden language tab blocks submitting
That is why setRequired() hangs off addConditionOn($active, …) — the field is
required only when that translation is marked active. Without the condition the form
cannot be submitted and the browser jumps to a field that is not visible.
A field that is not rendered ERASES the value
A field the template does not render is not submitted — and saving writes it as
empty. This is not limited to addHidden(): n:if on a form field does exactly
the same. The data disappears without a single error message and you find out from
a user complaint.
Edit defaults need the ToOne relations in includes
Without them the form offers an empty selection — and saving clears that relation, because empty is a valid value.
Read and write gates#
The base checks rights in two places, so you do not write them again in the presenter:
| Gate | When it runs | Which key it requires |
|---|---|---|
authorizeListing() |
in startup(), only for the show action |
:Admin:<Module>:<Presenter>:show |
authorizeFormSubmit() |
in createForm() before saving, only for the save and update buttons |
per formSubmitPrivilege(), see below |
The save key is derived from the server only (presenter + request action), never from a form field:
| Request action | Key |
|---|---|
add / edit |
…:add / …:edit |
another action of a presenter that has add or edit |
…:edit |
a presenter without add/edit (single-page settings) |
the key of the current action, typically …:show |
The write gate is needed because a form submit is a signal (?do=editForm-submit) that Nette
handles on any action, after action*(). A check in actionEdit() is not enough: a
submit sent to the list would pass with the view right only.
Procedure: a form on a custom action#
- The form belongs to an action other than
add/edit(example:sendEmailFormon invoices). - Override
formSubmitPrivilege()in the presenter and return that action's key by form name; leave other forms toparent::formSubmitPrivilege($form):
protected function formSubmitPrivilege(\Nette\Forms\Form $form): string
{
return $form->getName() === 'sendEmailForm'
? ':Admin:Invoicer:Invoice:sendEmail'
: parent::formSubmitPrivilege($form);
}
- Create the key in RBAC and enable it for the
adminrole. - A custom saving button outside
save/update(e.g. "Save the product first") calls$this->authorizeFormSubmit($button->getForm())itself, otherwise it saves without a gate.
Every show screen needs its key in the privilege table
The read gate is fail-closed: without a …:show row enabled for the admin role the
list is not shown even to the administrator (403).
Match the exception in formSubmitPrivilege() by form name
A condition on the action alone would give the same right to the other forms of the presenter that can be sent to that action.
Gates in signals and components#
The two gates above guard only the presenter's list and form. Everything else that changes
data writes its own gate: a hidden button protects nothing, a hand-built ?do=<signal>
address runs the action.
| Where | Gate | When to call it |
|---|---|---|
| a presenter signal or custom action | checkPrivilege($key) / checkCurrentPrivilege() (PrivilegeCheckTrait) |
as the first statement of the method |
a component signal (handleAdd, handleDelete…) |
requirePrivilege($key) from PrivilegeGuardTrait |
as the first statement, before loading data |
a component form (AdminComponentFormTrait) |
requirePrivilege($key) |
at the start of the save callback, pattern FeedEdit::saveFeedForm() |
| showing a link, tile or button | isAllowed($key) in the presenter and template, isTargetAllowed($key) in a component |
does not throw, returns bool |
checkPrivilege() is protected, so a component cannot call it. PrivilegeGuardTrait
therefore asks the presenter through the public isAllowed(), which reads the privileges
loaded once in startup(), so the gate costs no extra query. The page key
(:Admin:System:User:show) is built by resolvePageTarget($adminPage) from the page target,
never from a class name.
A component form does not get the write gate from the base
AdminComponentFormTrait takes over the way back and the update/save distinction
from createForm(), but it does not call authorizeFormSubmit(). Without
requirePrivilege() in the save callback anyone who may open the page can save the form.
The component gate is fail-closed
A presenter without isAllowed() (outside the admin branch) and a missing privilege end
the same way: 403. An empty key in isTargetAllowed() means "no".
Items of the edited record: owner context#
Components with the items of one record (replies and followers of an advert, payment items
of an advert, profile or post) receive the owner id in a signal parameter and in a hidden
field. Both can be rewritten in the browser. OwnerContextGuardTrait therefore takes the
owner from the server, from a callback the presenter hands to the component:
| Method | What it does |
|---|---|
setOwnerContext(fn() => $this->editedAdvert?->getId()) |
called by the presenter in createComponent…(); the callback is read only at signal or submit time, when action*() has loaded the record |
requireContextOwner($ownerId) |
signal: the id from the address must equal the context, otherwise 403 |
submittedOwnerId($posted) |
submit: returns the id from the context, or null when the context is missing or the hidden field differs; with null nothing is saved |
contextOwnerId() |
the id for loading items; an item is looked up within the context only, a foreign one is "not found" |
Procedure: a component with the items of one record
- In the component,
use PrivilegeGuardTrait; use OwnerContextGuardTrait;. - Start every mutating signal with
requirePrivilege(…)and right after it$ownerId = $this->requireContextOwner($ownerIdFromUrl). - In the form handler call
requirePrivilege(…), thensubmittedOwnerId($values->advertId ?? null); onnullstop without saving. - Load items filtered by
contextOwnerId(), not by the id from the request. - In the presenter:
->setOwnerContext(fn(): ?int => $this->editedAdvert?->getId())increateComponent…(). Example:app/UI/Admin/Bazaar/Presenters/AdvertPresenter.php.
Without setOwnerContext() the component does not work at all
The context is fail-closed: without it the component saves, deletes and opens nothing.
Without this binding a role allowed to edit through the component of one advert could
edit or delete an item of another one (a forged id was enough).
Field errors without {inputError}#
A server rule stops saving even where the template has no {inputError}. So the user sees
it, createForm() calls publishFieldErrors() in onError: on AJAX it sends the errors in
payload.fieldErrors, on a full render the layout puts them into data-server-field-errors
on <body>. The fieldErrors.js script places them at the field and reveals its tab; it
does not duplicate {inputError}. A component form (AdminComponentFormTrait) does the same
through the presenter.
Picker mode#
A presenter can serve as the content of a Picker: it renders its show
grid as a selection modal in a minimal layout, without adding, editing and bulk
actions. It is switched on in the grid factory:
if ($this->picker !== '') {
$grid->setPickerMode($this->picker)->setPickerLabelProperty('translations.name');
}
The parameters are persistent so they survive filtering and paging inside the modal.
pickerExclude (and the pickerExcludeSubtreeLft/Rgt pair for trees) hides "Select"
on records that must not be picked — typically so that a page cannot become its own
parent.
Without setPickerLabelProperty() the label is taken from the first non-identifier column
Sometimes that works out, sometimes it does not — and when that property is empty
for part of the records, the user ends up with an empty field after picking. When a
single property is not enough (several fields have to be combined), use
setPickerLabelCallback(), which receives the whole entity. In detail:
Picker.
The breadcrumb in the layout header#
The administration header (layouts/parts/shared/_header.latte, in both admin themes) shows
the screen's breadcrumb. Menu::renderBreadcrumb() draws it with the template
MenuComponent/Templates/Default/Breadcrumb.latte:
{capture $breadcrumbHtml}{control adminSystemMenu:breadcrumb 1}{/capture}
{if trim((string) $breadcrumbHtml) !== ''}
{$breadcrumbHtml}
{else}
<div class="admin-header__title">…page title…</div>
{/if}
The trail is Administration › ancestors of the active menu item › the active item › the screen:
| Crumb | Source | Link |
|---|---|---|
| Administration | :Admin:System:Page:dashboard |
yes |
| ancestors and the active item | items of menu 1 by the page-to-item relation; separators and unnamed items are skipped | only an item with a target; a folder without a screen is text |
| the screen | the page title from cms_admin_system_pages, only when the active item leads elsewhere (add, edit …) |
no, the last crumb is never a link |
The suffix after | is cut off the screen title, and from the form "Module - action" the
last part is taken with a capital first letter. The dashboard item is not repeated as a
crumb.
The component is the same instance as the left menu: loadMenuState() loads the items,
links and active state once per menu, so the breadcrumb costs no extra query.
A screen outside the menu has a plain title
Without an active menu item renderBreadcrumb() renders nothing and the header falls
back to the page title ($metaTitle, $pageTitle). A missing breadcrumb on a new
screen therefore means a missing page-to-menu-item relation, not a template bug.
Menu and dashboard#
The admin menu and the dashboard widgets follow the same key as the screen they lead to:
<presenter>:<action> of the target page, e.g. :Admin:System:UserGroup:show. The menu
and the screen therefore cannot contradict each other.
The menu (MenuComponent\Menu) shows items whose target the user has the privilege for. It
loads them through the Api; the active item comes from the page → item relation. It drops a
folder whose children were all removed by privilege pruning, and a section heading with no
item left below it. An item whose presenter does not exist (class_exists()) is printed as
text without a link. An item may carry params (JSON): two items over one presenter then
differ only in a preset. The keys must match persistent parameters of the target presenter.
The dashboard (:Admin:System:Page:dashboard) is a widget board. Every user arranges their
own layout, separately for each page, and every widget is a component in the Widgets/
folder of its module. The former fixed bands ("What waits for action" and the tiles) live on
as info boxes (one widget type per card) and the "Quick links" widget. How a widget is built, what it declares and how
it is guarded is described in Dashboard widgets.
A menu key that does not exist in the privilege table hides the item from everyone
The administrator included. Check 1.6 in
tests/Manual/Admin/privilege_enforcement_audit.php guards this; without it you find
out only when an item disappears from someone's menu. The same holds for widget keys,
which DashboardWidgetRegistryTest guards.
What to watch out for#
Redirecting to a raw URL throws the flash message away
redirectUrl() instead of redirect() means the user sees neither the confirmation
nor the error — the action happens and it looks like nothing did. Nette adds the flash
session key (_fid) only on redirect()/forward(). Return to the previous page through
redirectToFormBack(), which adds _fid to the raw URL itself.
A flash inside a modal needs its own anchor
Without one it renders outside the modal, where nobody sees it behind the open window.
After changing a form's structure the page has to be loaded twice
The first load still runs on the old shape from the cache. Do not conclude from the first attempt that your fix does not work.
The administration must not use Front traits or Front entities
It looks like a saving — both already exist, after all. But a Front entity is the result of hydrating a response for a visitor: it carries only what the front end needs and changes with it. An administration that reaches for it breaks on a change that has nothing to do with the administration.
Where to look#
| I want | Where |
|---|---|
| what the base class does | app/Core/Base/Admin/BasePresenter.php |
| the form skeleton and the way back | the same file → createForm(), resolveFormBackKey() |
| the language tabs and buttons | app/Core/Traits/Admin/Presenters/AdminEditFormComponentsTrait.php |
| the form helpers | app/Core/Traits/Admin/Presenters/AdminFormHelpersTrait.php |
| RBAC in the administration | app/Core/Traits/Admin/Presenters/PrivilegeCheckTrait.php |
| component gates and owner context | app/Core/Traits/Admin/Components/PrivilegeGuardTrait.php, OwnerContextGuardTrait.php |
| loading the page and the menu | app/Core/Traits/Admin/Presenters/PageInitTrait.php |
| the menu and its privilege pruning | app/UI/Admin/System/Components/MenuComponent/Menu.php |
| the header breadcrumb | Menu::renderBreadcrumb(), …/MenuComponent/Templates/Default/Breadcrumb.latte, layouts/parts/shared/_header.latte |
| the dashboard and its widgets | app/UI/Admin/System/Components/DashboardWidgetsComponent/, app/UI/Admin/<Module>/Widgets/, see Dashboard widgets |
| pretty admin addresses | app/Core/Routers/Factories/AdminRouteFactory.php |
| a finished example | app/UI/Admin/Blog/Presenters/TagPresenter.php + Traits/Forms/TagFormTrait.php |
Follow-up chapters: Working with tables · Dashboard widgets · Picker · Forms · Translations · Code conventions