marko/forms
Server-rendered form round trips for Marko. A form posts, validation fails, the user is redirected back, and the form shows each field’s errors with the previous input filled in. marko/forms provides each step once: the ValidationErrorResponder, next-request storage for the errors and old input, and template helpers that work in Latte and Twig alike.
Installing the package is the opt-in: every route that throws ValidationException for an HTML request gets the redirect back. JSON requests are left alone and keep getting the 422 response from marko/validation.
Installation
Section titled “Installation”composer require marko/formsIt needs a session driver (marko/session-file or marko/session-database) and a view driver (marko/view-latte or marko/view-twig). The template helpers need no engine-specific setup.
Configuration
Section titled “Configuration”return [ // Input fields that are never flashed back into the form after a failed // submission. Dot paths reach nested fields (for example 'user.password'). // The CSRF token and uploaded files are never flashed either. 'never_flash' => [ 'password', 'password_confirmation', 'current_password', ],
// Where ValidationErrorResponder sends the user when the request has // no Referer, or one from another origin. Must be a path on this site. 'fallback_redirect' => '/',];Both keys are required. fallback_redirect must start with a single /; anything else (a full URL, //host) throws FormsException.
A Complete Form
Section titled “A Complete Form”The controller validates with validateOrFail(). On success it redirects as usual. On failure ValidationErrorResponder turns the ValidationException into the redirect back; the controller needs nothing extra:
use Marko\Routing\Attributes\Get;use Marko\Routing\Attributes\Post;use Marko\Routing\Http\Request;use Marko\Routing\Http\Response;use Marko\Validation\Contracts\ValidatorInterface;use Marko\View\ViewInterface;
class RegisterController{ public function __construct( private ViewInterface $view, private ValidatorInterface $validator, ) {}
#[Get('/register')] public function show(): Response { return $this->view->render('account::register'); }
#[Post('/register')] public function store( Request $request, ): Response { $this->validator->validateOrFail($request->post(), [ 'name' => ['required', 'string', 'max:100'], 'email' => ['required', 'email'], 'password' => ['required', 'string', 'min:12', 'confirmed'], ]);
// ...create the account
return Response::redirect('/welcome', 303); }}The template prints the CSRF field, each field’s first error, and the old input:
<form method="post" action="/register"> {csrf_field()}
<label>Name <input name="name" value={old('name')}></label> {if $errors->has('name')}<p class="error">{$errors->first('name')}</p>{/if}
<label>Email <input name="email" type="email" value={old('email')}></label> {if $errors->has('email')}<p class="error">{$errors->first('email')}</p>{/if}
<label>Password <input name="password" type="password"></label> <label>Confirm <input name="password_confirmation" type="password"></label> {if $errors->has('password')}<p class="error">{$errors->first('password')}</p>{/if}
<button>Create account</button></form>The same template in Twig:
<form method="post" action="/register"> {{ csrf_field() }}
<label>Name <input name="name" value="{{ old('name') }}"></label> {% if errors.has('name') %}<p class="error">{{ errors.first('name') }}</p>{% endif %}
<label>Email <input name="email" type="email" value="{{ old('email') }}"></label> {% if errors.has('email') %}<p class="error">{{ errors.first('email') }}</p>{% endif %}
<label>Password <input name="password" type="password"></label> <label>Confirm <input name="password_confirmation" type="password"></label> {% if errors.has('password') %}<p class="error">{{ errors.first('password') }}</p>{% endif %}
<button>Create account</button></form>The password fields are never refilled: password and password_confirmation are in never_flash.
Testing a Form
Section titled “Testing a Form”The HTTP test client asserts on the flashed errors and old input through FlashedFormStateInterface, without consuming them:
it('sends the registration form back with errors', function (): void { $this->http->post('/register', ['name' => '', 'email' => 'not-an-email', 'password' => 'short']) ->assertRedirect() ->assertSessionHasErrors(['name', 'email', 'password' => 'at least 12']) ->assertSessionHasInput('email', 'not-an-email') ->assertSessionMissingInput('password');});What the Responder Does
Section titled “What the Responder Does”ValidationErrorResponder is an HTTP exception responder: the routing pipeline asks it before rendering an HTTP exception. When a controller or middleware throws ValidationException, it:
- Passes (returns
null) if the request wants JSON (Request::wantsJson()) or sends a JSON body (Request::isJson()), so API clients get the422JSON response. - Flashes the errors and
$request->post()for the next request, leaving out the_tokenCSRF field, uploaded files and everynever_flashfield. - Redirects with
303 See Otherto theReferer--- only when it has the request’s host and port --- and otherwise tofallback_redirect. Only the Referer’s path and query are used.
The scheme is not compared, so a site behind a TLS-terminating proxy (the app sees http, the browser sends an https Referer) still redirects back. A Referer from another host, another port, or with a non-HTTP scheme goes to the fallback.
It also passes on a route without a session (one that excludes SessionMiddleware), so a stateless route keeps the 422 response, and on every other HTTP exception. The redirect is built at the depth the exception was thrown, so it still flows back out through every middleware: SessionMiddleware saves the flashed state, and headers added by outer middleware are kept.
To handle validation failures differently, register your own responder with a higher priority, or replace this one with a #[Preference].
Template Helpers
Section titled “Template Helpers”FormTemplateFunctionProvider adds four functions to every template, in every engine:
| Function | Returns |
|---|---|
csrf_field() | <input type="hidden" name="_token" value="...">, printed unescaped (the token inside is escaped) |
csrf_token() | The CSRF token, escaped like any value --- for a <meta> tag or JavaScript |
old(field, default) | The flashed input at a dot path ('address.city', 'items.0.sku'), or default |
errors() | The flashed ValidationErrors |
FormViewDataProvider shares two variables with every render:
| Variable | Type | Latte | Twig |
|---|---|---|---|
$errors | ValidationErrors | {$errors->first('email')} | {{ errors.first('email') }} |
$old | OldInput | {$old['email']}, {$old['address.city']} | {{ old.email }}, {{ old.address.city }} |
Old values are escaped like any other output. $old is read-only array access; a missing field reads as null. Data passed to render() under the key errors or old wins over the shared value. ValidationErrors has has(), first(), get(), all(), keys(), isEmpty() and count() --- see marko/validation.
The helpers read the state of the request the user was redirected to. On a request where no session was started --- a visitor without a session cookie, a CLI render, a queued mail --- they see no errors and no old input, and never start a session just to look.
Flashing Manually
Section titled “Flashing Manually”To flash errors and input without a ValidationException, for example after a check that does not go through the validator, use FlashedFormStateWriter:
use Marko\Forms\FlashedFormStateWriter;use Marko\Routing\Http\Response;use Marko\Validation\Validation\ValidationErrors;
public function __construct( private FlashedFormStateWriter $flashedFormStateWriter,) {}
// ...$this->flashedFormStateWriter->flash( new ValidationErrors(['coupon' => ['This coupon has expired.']]), $request->post(),);
return Response::redirect('/checkout', 303);It applies the same exclusions as the responder: _token, uploaded files and never_flash fields are left out.
Reading Flashed State
Section titled “Reading Flashed State”Code that needs the errors or old input outside a template reads them through FlashedFormStateInterface. It is the only reader: the view helpers, the Inertia errors prop and test assertions all use it, and none of them knows the storage keys.
use Marko\Validation\Contracts\FlashedFormStateInterface;
public function __construct( private FlashedFormStateInterface $flashedFormState,) {}
if ($this->flashedFormState->hasErrors()) { $emailError = $this->flashedFormState->errors()->first('email'); $city = $this->flashedFormState->old('address.city', '');}Reading never consumes the state. It is stored with next-request flash data, so it is available on exactly the request after the failed submission and gone on the one after that, whether or not anything read it.
Errors
Section titled “Errors”| Exception | When |
|---|---|
FormsException | A never_flash entry is not a non-empty string, fallback_redirect is not a local path, or a template writes to $old |
InvalidFlashDataException | The flashed input holds an object other than an uploaded file (from marko/session) |
ConfigNotFoundException | forms.never_flash or forms.fallback_redirect is missing |
API Reference
Section titled “API Reference”ValidationErrorResponder
Section titled “ValidationErrorResponder”#[HttpExceptionResponder] that redirects a failed HTML form back with its errors and old input flashed.
use Marko\Forms\ValidationErrorResponder;
public function respond(HttpExceptionInterface $exception, Request $request): ?Response;FlashedFormStateWriter
Section titled “FlashedFormStateWriter”use Marko\Forms\FlashedFormStateWriter;
public function flash(ValidationErrors $errors, array $input): void;SessionFlashedFormState
Section titled “SessionFlashedFormState”Implements FlashedFormStateInterface over FlashDataInterface. marko/forms binds it as the implementation.
use Marko\Forms\SessionFlashedFormState;
public function errors(): ValidationErrors;public function hasErrors(): bool;public function old(string $key, mixed $default = null): mixed;FormTemplateFunctionProvider
Section titled “FormTemplateFunctionProvider”#[TemplateFunctions] provider for csrf_field(), csrf_token(), old() and errors().
use Marko\Forms\View\FormTemplateFunctionProvider;
public function functions(): array;public function csrfField(): string;FormViewDataProvider
Section titled “FormViewDataProvider”#[ViewData] provider for $errors and $old.
use Marko\Forms\View\FormViewDataProvider;
public function data(): array; // ['errors' => ValidationErrors, 'old' => OldInput]FormsConfig
Section titled “FormsConfig”use Marko\Forms\Config\FormsConfig;
public function neverFlash(): array; // list<string> of dot pathspublic function fallbackRedirect(): string;Related Packages
Section titled “Related Packages”marko/routing--- asksValidationErrorResponderbefore rendering an HTTP exceptionmarko/validation--- throwsValidationExceptionand definesFlashedFormStateInterfacemarko/session--- next-request flash data the form state is stored inmarko/security--- CSRF tokens forcsrf_field()andcsrf_token()marko/view--- template function and view data providersmarko/inertia--- fills itserrorsprop from the flashed errorsmarko/testing---assertSessionHasErrors()and the other flashed form state assertions