Skip to content
V
For developers
Architecture, conventions, the core and security
UI layer / Admin presenter

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#

  1. 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.
  2. The presenter into app/UI/Admin/<Module>/Presenters/XxxPresenter.php: final, extends BasePresenter, $translatorDomain, a private property for the edited record, a constructor taking DataGridFactory and the manager.
  3. The actions: actionEdit(int $id) loads into the property and calls $this->error() when nothing is found; actionAdd() nulls the property; renderAdd() / renderEdit() set pageTitle and the languages. renderAdd() ends with setView('edit').
  4. The grid in createComponentGrid() — the data source is the manager, not a query. Do not forget setIncludes() for whatever the grid actually shows, and setItemsPerPage($this->getAdminItemsPerPage()).
  5. The form into its own trait Presenters/Traits/Forms/XxxFormTrait.php (procedure below).
  6. The templates Templates/<Presenter>/show.latte and edit.latte.
  7. A row in cms_admin_system_pages with the presenter (with the leading colon, :Admin:Blog:Tag) and action pair, plus translations in cms_admin_system_page_texts.
  8. A menu item and its link to the page (cms_admin_system_page_menu_item_relations).
  9. The translation keys into app/Locale/<module>/<locale>/admin.<LOCALE>.neon under admin.<module>.presenters.<presenter> — in every language right away.
  10. An RBAC key for actions not everyone should see; conditional display in the grid through isAllowed().
  11. 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.

  1. createComponentEditForm() in the FormTrait passes createForm() a callback returning bool — 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;
}
  1. 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.
  2. 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;
}
  1. 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
  1. The way back is per form. The address of the previous page is taken from Referer on the first GET render, stored in the session under a random key, and the key goes into the hidden backKey field. 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#

  1. AdminFormHelpersTrait gives you getFormLanguages() and str(); AdminEditFormComponentsTrait the language tabs and the button bar.
  2. 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'));
}
  1. When building the entity look the translation up through getTranslation($langId) and create the missing ones including setParent().
  2. includes: ['translations'] belongs in the load inside actionEdit() — without it the form offers nothing.
  3. 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 panel show active. The bar also renders the active_<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#

  1. The form belongs to an action other than add/edit (example: sendEmailForm on invoices).
  2. Override formSubmitPrivilege() in the presenter and return that action's key by form name; leave other forms to parent::formSubmitPrivilege($form):
protected function formSubmitPrivilege(\Nette\Forms\Form $form): string
{
    return $form->getName() === 'sendEmailForm'
        ? ':Admin:Invoicer:Invoice:sendEmail'
        : parent::formSubmitPrivilege($form);
}
  1. Create the key in RBAC and enable it for the admin role.
  2. 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

  1. In the component, use PrivilegeGuardTrait; use OwnerContextGuardTrait;.
  2. Start every mutating signal with requirePrivilege(…) and right after it $ownerId = $this->requireContextOwner($ownerIdFromUrl).
  3. In the form handler call requirePrivilege(…), then submittedOwnerId($values->advertId ?? null); on null stop without saving.
  4. Load items filtered by contextOwnerId(), not by the id from the request.
  5. In the presenter: ->setOwnerContext(fn(): ?int => $this->editedAdvert?->getId()) in createComponent…(). 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.

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

Cookie settings