nette/forms 3.3 has been released

David Grudl
founder | 8322
+
+1
-

nette/forms 3.3.0, which brings modernized CSRF protection, a cleaner Latte integration, and several backward-compatibility changes.

Requires PHP 8.3 or newer.

Security: new CSRF protection

Forms now defend against CSRF by checking the Sec-Fetch-Site header (strict same-origin) instead of the previous SameSite cookie. These headers are added by the browser itself and cannot be spoofed even with an XSS vulnerability, so the protection needs no token and no server-side state. The article CSRF Protection in Nette: No More Tokens Thanks to Sec-Fetch Headers describes it in detail.

What this means in practice:

  • Token-based protection ($form->addProtection()) is marked as deprecated. It still works, so there's no need to rewrite anything in a hurry, but the default protection fully replaces it.
  • The check is now strictly same-origin. The previous mechanism (SameSite cookie) considered submissions between subdomains of the same site to be safe; the new check requires an exact origin match (scheme + host + port).

If you legitimately accept the form from a different origin (a foreign site or another subdomain), you can disable the built-in check with $form->allowCrossOrigin(). But beware: this disables the CSRF protection completely and the form will accept a request from anywhere, so do it deliberately. If you want to allow only specific origins, disable the check and verify the origin yourself against your own list using the Origin header:

$form->allowCrossOrigin(); // disables the built-in check

$allowed = ['https://app.example.com', 'https://admin.example.com'];
$form->onValidate[] = function (Form $form) use ($httpRequest, $allowed) {
	if (
		!$httpRequest->isFrom(Nette\Http\FetchSite::SameOrigin)
		&& !in_array((string) $httpRequest->getOrigin(), $allowed, strict: true)
	) {
		$form->addError('Invalid request origin.');
	}
};

You get $httpRequest in a presenter via $this->getHttpRequest(). getOrigin() returns null if the header is missing, so an unknown origin is rejected.

Latte: unifying {formContext} and {formContainer}

Until now there were two separate tags for working with a form without rendering <form>: {formContext} set up the context of an entire form (taken from the presenter, when the <form> itself is rendered elsewhere), whereas {formContainer} stepped into a nested container of an already-open form. The difference was subtle and confusing for many people; it wasn't obvious which one to reach for.

Now a single construct {form scope name} encompasses both:

  • the scope keyword tells {form} not to emit the <form> HTML tag, only to push the form/container onto the stack (so {input}, {label}, {inputError} bind to it),
  • if a form is already active, the name is resolved relatively to it (the {formContainer} behavior), otherwise from the top level (the {formContext} behavior).

A single notation thus covers both former cases. The original {formContext} and {formContainer} remain functional, but will eventually be deprecated in favor of {form scope}.

Latte: {form detached name}

It lets you create nested forms! HTML forbids nested <form> tags, and the browser discards the inner <form> and “glues” its inputs to the outer form. The detached keyword solves this: it emits an empty <form></form> up front and links every control to it via the HTML form="..." attribute. The controls are thus submitted to the correct form regardless of where they sit in the DOM.

If you have two nested forms, render the outer one (the one that surrounds the other) as {form detached}. Its empty <form> tag is closed right at the start, so the inner form is no longer nested and both work correctly. Alternatively, render both as detached.

Latte: requirements and internal changes

  • A missing comma before {form} arguments is now deprecated – write {form name, attr=val} instead of {form name attr=val}. The reason is precisely the ability to write keywords like scope/detached after the name.
  • Support for Latte 2 and Latte 3.0 has been removed – Latte 3.1.4+ is now required.
  • The internal Runtime was rewritten from a static to a regular (non-static) class. This is an internal change that affects you only if you called Runtime's static methods directly from your own code.

New: callback in addSubmit()

You can now pass a handler to the button right when creating it, without a separate ->onClick[] =:

$form->addSubmit('send', 'Submit', function (SubmitButton $button, $values) {
	// ...
});

Backward-compatibility changes

setValues() / setDefaults() – preparing to narrow the accepted types. The supported input is now iterable|stdClass (array, Traversable, stdClass). Functionally nothing changes yet, but if you pass a different object type – typically an entity or DTO, which used to be cast to an array via (array) (which produces “mangled” keys for private and protected properties) – you'll get a deprecation notice. In that case, convert the data to an array yourself.

Other behavior changes:

  • RadioList / CheckboxList: getControlPart() and getLabelPart() throw an exception for a non-existent key (previously they produced a nonsensical result instead).
  • Negative validation rules (~Rule) now throw an exception (previously just a deprecation notice).

Removed (previously deprecated): DataClassGenerator, LatteRenderer, getValues(true) (use getValues('array')), the $default parameter in getOption() (use the ?? operator).

Newly deprecated (still works, only emits a notice) – some magic properties. Instead of $control->htmlName, caption, omitted, filled, options, selectedItem(s), submittedBy and $form->action, method, renderer, use the corresponding methods (getHtmlName(), getCaption(), isOmitted(), isFilled(), getOptions(), getSelectedItem(), isSubmittedBy(), getAction(), getMethod(), getRenderer()).