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#
- A
FormTraitnext to the component, with acreateComponentForm(): Formmethod. - The skeleton per the example above.
- Split a longer form into
addXxxFields(Form $form)methods; populate selects inprepare*(). onSuccesswith 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();
}
}
- 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. - 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)#
- Prefill through
setDefaults()on the container, not by setting values on the individual controls. - On an already submitted form
addCopy(null, $defaults)does not work — it internally callsContainer::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
- 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 thensetValues()). - 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.
- Put the token into the form as a hidden field.
- 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. - 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:
- 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. - Which button submitted the form? In the administration only
saveandupdatesave; a custom submit runs but does not save. - Does the handler take
ArrayHash $values? See themixedwarning above. - Does the button have
setValidationScope([])? Then the values are empty. - Does the template render every field? An unrendered field is saved as empty.
- Did the manager return
isSuccess()? A failure without a flash message is silent. - 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.php → createForm() |
| 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