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

Forms#

The core is app/Core/Forms/ — a custom Form class on top of Bootstrap forms, a factory and a few custom controls. A form is never written directly in a presenter or a component: it belongs in a FormTrait next to them.

Where the form lives Path
a component XxxComponent/Traits/FormTrait.php
a presenter Presenters/Traits/Forms/XxxFormTrait.php
an admin presenter the same, but the skeleton is built by createForm() — see Admin presenter

The canonical skeleton#

protected function createComponentForm(): Form
{
    $form = FormFactory::create();
    $form->setMethod('POST');
    $form->addProtection();
    $form->useDomainTranslator($this->translator, $this->translatorDomain);
    $form->setHtmlAttribute('role', 'form');
    $form->setHtmlAttribute('class', 'ajax');

    $form->addText('name', 'name.label')
        ->setRequired('name.required')
        ->setHtmlAttribute('placeholder', 'name.placeholder')
        ->setOption('description', 'name.description');   // the key only!

    $form->addSubmit('send', 'send');

    $form->onSuccess[] = [$this, 'onSuccess'];
    return $form;
}
Line Why it is there
FormFactory::create() returns App\Core\Forms\Form, not Nette's Form — otherwise you lose every control below
setMethod('POST') always explicitly
addProtection() the CSRF token, mandatory
useDomainTranslator(…) labels are then bare keys ('name.label'), the domain is added by the translator
role('form') accessibility

The old notation stays functional — it is just not written in new code

setTranslator() + setTranslatorDomain() + $t = $form->getTranslateFnc() still works and is not rewritten in finished forms. Only new forms are written canonically.

What Form adds on top of Nette#

Method What it does
addEditor($name, $caption) a textarea with data-editor="full" → the full TinyMCE. For administrator content (pages, articles, emails, PDF templates)
addSimpleEditor($name, $caption) a textarea with data-editor="simple" → a stripped-down editor: bold, italics, colours, lists, a table. No links, images or source code
addCheckbox($name, $caption) a flat checkbox without the Bootstrap wrapper (FlatCheckboxInput)
addCheckboxList($name, $label, $items) a checkbox list that keeps Html in an option's caption
addImageUpload($name, $caption, …) a field for a single image plus the satellite fields <name>CurrentId and <name>Delete
useDomainTranslator($translator, $domain) a translator with a domain prefix

Every add*Editor and addImageUpload also has a static variant (addEditorControls(), addImageUploadControls()) taking a container — used inside a Multiplier row, where there is only a Nette\Forms\Container without our methods.

User content gets addSimpleEditor(), not addEditor()

The full editor allows links, images and pasted source code. Wherever the content is written by a user (front-end forms, advert parameters) the stripped-down variant is required — and its whitelist has to stay identical to the server-side sanitisation (EditorHtmlSanitizer::ALLOWED_HTML), because a restriction that lives only in JavaScript is bypassed by sending the request outside the browser.

Procedure: adding a form to a component#

  1. A FormTrait next to the component, with a createComponentForm(): Form method.
  2. The skeleton per the example above.
  3. Split a longer form into addXxxFields(Form $form) methods; populate selects in prepare*().
  4. onSuccess with a typed signature:
public function onSuccess(Form $form, ArrayHash $values): void
{
    $entity = new ContactMessage();
    $entity->setEmail($values['email']);

    $result = $this->contactMessageManager->save($entity);

    if ($result->isSuccess()) {
        $this->flashMessage('result.success', 'alert-success');
        $form->setValues([], true);
    } else {
        $this->flashMessage('result.error', 'alert-danger');
    }

    if ($this->presenter?->isAjax()) {
        $this->redrawControl();
    }
}
  1. Send the flash message as a bare key — a component template already has its translator set to the component's domain and {_$flash->message} adds it.
  2. Add the translation keys at the same time, in every language — see Translations.

onSuccess(Form $form, mixed $values) breaks EVERY submit

Nette derives the shape of the values from the second parameter ($this->getValues(Helpers::getSingleType($params[1]))). With mixed that becomes new ReflectionClass('mixed')ReflectionException: Class "mixed" does not exist. There is no fallback. An untyped parameter is wrong too — you get $this or the button instead of the values. Always ArrayHash $values.

Exactly this made the order state machine impossible to run from the administration, and nobody noticed for months: the harness called the Api endpoint directly and never submitted the form. The check: grep -rn 'onSuccess\[\] = function' app | grep mixed.

Procedure: rendering a form in Latte#

{form editForm}
    <div class="form-group">
        {label name /}
        {input name}
        <span class="errors" n:ifcontent>{inputError name}</span>
        <small class="form-text text-muted" n:ifcontent>
            {$form['name']->getOption('description') ? _($form['name']->getOption('description')) : ''}
        </small>
    </div>

    <label>
        {input active:}
        {label active}{_$form['active']->caption}{/label}
    </label>
{/form}
Element How it is written
a field error inline {inputError name} next to that field
a checkbox {input active:}the colon is mandatory
a checkbox caption {label active}{_$form['active']->caption}{/label}, never {label active /}
a description only the key in PHP, translated in Latte through _()

Do not use {control $form errors} in a hand-rendered form

A summary at the top means the user sees "Fill in the name" but not which field it is — in a form with language tabs the offending field is often hidden in an inactive tab.

A checkbox renders its own caption

{label active /} (self-closing) prints the caption a second time. Hence the variant with {_…->caption} inside {label}.

n:if on a form field ERASES its value

A field that is not rendered is not submitted, and saving writes it as empty. The same holds for addHidden(). If you need a field to be conditional, handle it while building the form (do not add it), not in the template. The data disappears with no error message.

Procedure: repeated groups of fields (Multiplier)#

  1. Prefill through setDefaults() on the container, not by setting values on the individual controls.
  2. On an already submitted form addCopy(null, $defaults) does not work — it internally calls Container::setDefaults(), which on a submitted form only sets disabled fields. The copy comes out empty. Correctly:
$copy = $outerMultiplier->addCopy();     // no defaults
$copy->setValues($row);                  // direct controls — always works
  1. A nested Multiplier on a submitted form ignores programmatically supplied values — it builds the copies from the submitted data, not from the values. A new container is not in the submitted data, so the inner copies come out as none. You have to add them explicitly (getComponent($i, false) ?? $nested->addCopy($i) and then setValues()).
  2. A row needs its own class on the wrapper — without it the controls fall apart in the layout.

setValidationScope([]) empties getValues()

Nette marks controls outside the submitter's validation scope as omitted, and getValues() skips them. On a Multiplier it looks especially insidious: getValues()->items returns [{}] — the row exists but is empty.

Auxiliary AJAX buttons (recalculate, "recompute", a Picker redraw) have an empty scope on purpose, so they do not block on required while the administrator is still assembling the form. Their handlers therefore have to read the controls directly — $multiplier->getContainers() plus $control->getValue(). Because of this, "Recalculate" and the shipping prefill never worked in the e-shop: the error message rendered into a snippet that was not being redrawn, so the failure was silent.

An AJAX button's handler must also redraw the snippet holding its error message

Otherwise the button "does nothing" — whether an error occurred or not.

Procedure: file uploads#

The shared UploaderDropzone uploads files ahead of time through an AJAX signal into a temporary folder keyed by a token; the final submit reads them from there using the hidden dropzoneToken field and the hidden uploaderItems[…] entries.

  1. Put the token into the form as a hidden field.
  2. Do NOT regenerate the token on a POST redraw — read it from the submitted data and set it with setToken($posted). prepareTempDir(forceNew: true) would create a new empty folder.
  3. Create a new folder only on a fresh GET (in a component that is recognisable by getSignal() === null).

Without taking the token over, an in-progress upload disappears on the first validation error

The scenario: the user uploads an image → submits the form → validation fails on something else → the snippet with the gallery is redrawn → the server HTML knows only the files from the database, not those in the temporary folder → the hidden uploaderItems entries disappear. The user fixes the error, submits again, and the record is saved without the image — even though the file is still sitting in the temporary folder. The happy path works, so it only surfaces during debugging, when validation fails often.

Saving replaces the whole list of attachments

If the upload field is not loaded, an empty list is saved and the attachments disappear. It is the same mechanism as with collections — see Hydrators, the section on saving an object graph.

Procedure: the form was submitted but nothing was saved#

Cheapest first:

  1. Did a flash arrive? If not, check whether the code redirects through redirectUrl() — that throws the flash away and the action looks like it never ran.
  2. Which button submitted the form? In the administration only save and update save; a custom submit runs but does not save.
  3. Does the handler take ArrayHash $values? See the mixed warning above.
  4. Does the button have setValidationScope([])? Then the values are empty.
  5. Does the template render every field? An unrendered field is saved as empty.
  6. Did the manager return isSuccess()? A failure without a flash message is silent.
  7. Only then look into the database.

The form layer needs at least one test that ACTUALLY submits a form

Building the form or calling the Api endpoint beneath it will not catch this class of bug — both pass even when the form cannot be submitted. Only a POST or a live click-through in a browser catches it.

After changing a form's structure, load the page twice

The first load still runs on the old shape from the cache.

Dynamic parameters#

Alongside hand-written forms there is a parameter system (app/Core/Form/Parameters/): the field is assembled from configuration in the database rather than from code. Classifieds, the company catalogue and the e-shop all use it — every category has a different set of fields and those can be changed from the administration without touching the code.

The behaviour in the administration is described in Categories and parameters.

Where to look#

I want Where
the custom controls and helpers app/Core/Forms/Form.php
the form factory app/Core/Forms/FormFactory.php
the custom inputs app/Core/Forms/Inputs/
the admin form skeleton app/Core/Base/Admin/BasePresenter.phpcreateForm()
the shared uploader app/UI/Admin/Base/Components/UploaderDropzoneComponent/
the editor sanitisation app/Core/Utils/Helpers/EditorHtmlSanitizer.php
the dynamic parameters app/Core/Form/Parameters/
the binding convention skills/code-standard/cs-conv-forms/, skills/code-standard/cs-form-setup/

Follow-up chapters: Admin presenter · Picker · Latte templates · Translations · Code conventions