Forms & Validation
Pop\Form\Form is one object holding a form's fields, their validation rules and their current values. It
renders the markup, takes the submission back in, decides whether it passed, and re-renders itself with the
errors and submitted values in place.
Building a Form#
A field configuration array is the usual starting point. Each key is a field name — the name attribute, and
the key you read the value back by — and each value describes the field:
use Pop\Form\Form;
use Pop\Validator;
$form = Form::createFromConfig([
'username' => [
'type' => 'text',
'label' => 'Username:',
'required' => true,
'hint' => 'Letters and numbers only.',
'validators' => [new Validator\AlphaNumeric()]
],
'email' => [
'type' => 'email',
'label' => 'Email:',
'required' => true,
'validators' => [new Validator\Email()]
],
'submit' => [
'type' => 'submit',
'value' => 'Register'
]
]);
echo $form;
That renders a complete <form>: a <fieldset>, a <dl> of label and input pairs, required="required" on
the two required fields, and class="required" on their labels. The form defaults to POST with the current
request URI as its action; createFromConfig($config, $container, $action, $method) takes both, and
setAction() and setMethod() change them afterward.
The keys a field config understands are the same across every field type:
| Key | Effect |
|---|---|
type |
the element to build — text, email, password, textarea, select, checkbox, checkbox-set, radio-set, file, csrf, hidden, submit, and the rest of the HTML input types |
label |
the <label> text |
value / values |
the initial value; values is the option list for select, checkbox-set and radio-set |
required |
adds required="required" and a not-empty check, with required_message overriding the text |
validators |
one validator or an array of them (see below) |
attributes |
an array of extra HTML attributes |
hint |
help text rendered next to the field and wired to it with aria-describedby |
selected / checked |
the pre-selected option or checked box |
disabled / readonly |
the matching HTML attributes |
Elements can also be built and added one at a time, which is what you want when a field appears only under some condition:
$company = new Pop\Form\Element\Input\Text('company');
$company->setLabel('Company:')
->setRequired(true)
->setAttribute('size', 40);
$form->insertFieldAfter('username', $company);
addField() appends, insertFieldBefore() and insertFieldAfter() place a field relative to another by
name, and removeField() takes one out again. hasField(), getField() and getFields() inspect what is
there, and reset() clears every value while leaving the fields in place. The config array is shorter for
everything that is not conditional.
A form of any size is worth putting behind a named class, so the controller stays about the request and the form stays about the fields:
<?php
namespace App\Form;
use Pop\Form\Form;
use Pop\Validator;
class RegisterForm extends Form
{
public static function build(): RegisterForm
{
return self::createFromConfig([
'username' => [
'type' => 'text',
'label' => 'Username:',
'required' => true,
'validators' => [new Validator\AlphaNumeric()]
],
'password' => [
'type' => 'password',
'label' => 'Password:',
'required' => true,
'validators' => [
new Validator\LengthGreaterThanEqual(8, 'Your password must be at least 8 characters.')
]
],
'submit' => ['type' => 'submit', 'value' => 'Register']
]);
}
}
createFromConfig() constructs new static(), so a subclass gets its own type back. Name the factory
something other than create() — Form inherits a static create() from Pop\Dom\Child with an
incompatible signature, and redeclaring it is a fatal error at class-load time.
The Submit Cycle#
Three calls do the work: setFieldValues() feeds the submission in, isValid() runs every field's rules, and
rendering the form afterward produces the version with errors and repopulated values.
<?php
namespace App\Http\Controller;
use App\Form\RegisterForm;
use Pop\View\View;
class SignupController extends AbstractController
{
public function register(): void
{
$form = RegisterForm::build();
if ($this->request->isPost()) {
$form->setFieldValues($this->request->getPost());
if ($form->isValid()) {
// create the account, then redirect
Pop\Http\Server\Response::redirectAndExit('/welcome');
return;
}
}
$view = new View(__DIR__ . '/../../../view/register.phtml');
$view->form = $form;
$this->response->setBody($view->render());
$this->response->send();
}
}
The view assigns the object and echoes it — Form::__toString() renders it, so a template needs one line for
the whole form:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Register</title>
</head>
<body>
<h1>Create an account</h1>
<?=$form; ?>
</body>
</html>
Because the form object carries the values, the invalid path needs nothing extra: $form already has
username filled in with what was submitted and an error attached to password. Rendering it emits the error
markup inline —
<input type="text" name="username" id="username" value="bad$name" required="required"
aria-invalid="true" aria-describedby="username-error" />
<div class="error" id="username-error" role="alert">
<span>The value must only contain alphanumeric characters.</span>
</div>
— with aria-invalid and aria-describedby wired up automatically, and removed again once the field passes.
A field carrying a hint gets a <span id="{name}-hint"> and is listed in the same aria-describedby.
Errors are readable as data too, which is what an API endpoint wants:
$form->setFieldValues($this->request->getParsedData());
if (!$form->isValid()) {
$this->response->setCode(422);
$this->response->setBody(json_encode(['errors' => $form->getAllErrors()]));
$this->response->send();
return;
}
getAllErrors() returns ['username' => ['The value must only contain alphanumeric characters.']] — field
name to list of messages, only for fields that failed. getErrors($name) takes one field and always returns
an array. getFieldValue($name) and toArray() read the values back after validation.
A password field validates against the submitted value but blanks its own value attribute when it
renders, so a failed submission never echoes the password back into the page. Read the values you need
before rendering, since getFieldValue('password') returns '' afterward. 'render' => true on the field
opts out.
Validate before you render. render() builds the markup on the first call and reuses it afterward, so
calling isValid() first is what puts the error markup onto the fields.
Validators#
A validator is anything callable that returns null when the value is acceptable and a message string when it
is not. pop-validator ships the common ones, and its constructor takes the value to compare against first
and an optional replacement message second:
use Pop\Validator;
$fields['password'] = [
'type' => 'password',
'required' => true,
'validators' => [
new Validator\LengthGreaterThanEqual(8, 'Your password must be at least 8 characters.'),
new Validator\NotContains(' ', 'Your password cannot contain spaces.')
]
];
Several validators on one field all run, and every failure adds a message, so a field can report more than one
problem at a time. AlphaNumeric, Email, Url, Numeric, RegEx, LengthBetween, GreaterThanEqual,
In and NotEmpty cover most of it; the component has roughly a hundred more.
A closure is the shortest route to a rule that is specific to one form:
$fields['username'] = [
'type' => 'text',
'label' => 'Username:',
'validators' => [
function ($value) {
if (str_contains((string)$value, ' ')) {
return 'A username cannot contain spaces.';
}
}
]
];
Returning nothing is returning null, so the success branch needs no return. Any other callable works
identically — [new App\Validator\NotReserved(), 'validate'] for a rule shared across several forms.
A checkbox validates on the value it submits rather than on whether it is checked, so give an "I accept
the terms" box a value rule rather than required.
Use a single-option checkbox-set for anything that has to be ticked. Its value genuinely is the array of
boxes that were checked, so required behaves:
$fields['terms'] = [
'type' => 'checkbox-set',
'label' => 'Terms:',
'values' => ['yes' => 'I accept the terms'],
'required' => true,
'required_message' => 'You must accept the terms.'
];
Keep the option keys non-numeric. A key such as '1' becomes an integer array key in PHP, and
setFieldValues() then fails with Pop\Dom\Child::getAttribute(): Return value must be of type ?string, int returned.
Filtering Submitted Values#
A submitted value goes straight back into the field it came from when the form re-renders, so it reaches the
page unescaped unless something intervenes. Pop\Filter\Filter objects added to the form run over every value
as it is set:
use Pop\Filter\Filter;
$form->addFilter(new Filter('strip_tags'))
->addFilter(new Filter('htmlentities', [ENT_QUOTES, 'UTF-8']));
$form->setFieldValues($this->request->getPost());
strip_tags removes markup; htmlentities makes what remains safe to put back inside a value="…"
attribute, quotes included. Add them before setFieldValues() — a filter added afterward has nothing left to
filter.
The encoding then travels with the value, which matters once the form passes and you want to store it. Swap the filters for the inverse before reading the values out:
if ($form->isValid()) {
$form->clearFilters();
$form->addFilter(new Filter('html_entity_decode', [ENT_QUOTES, 'UTF-8']));
$values = $form->filter($form->toArray());
}
A filter's third constructor argument excludes named fields, so new Filter('strip_tags', null, 'body')
leaves one field's markup alone. Filtering is a blunt pass over everything and is not a substitute for a
validator — Requests & Responses covers the same mechanism applied at the
request level.
Fieldsets, Legends and Containers#
createFromFieldsetConfig() takes an array of field-config arrays instead of one, and renders each as its own
<fieldset>. Giving an entry a string key turns that key into the fieldset's <legend>; an entry with a
numeric key renders without one:
$form = Pop\Form\Form::createFromFieldsetConfig([
'Account' => [
'username' => ['type' => 'text', 'label' => 'Username:', 'required' => true],
'email' => ['type' => 'email', 'label' => 'Email:', 'required' => true]
],
'Profile' => [
'first_name' => ['type' => 'text', 'label' => 'First Name:'],
'last_name' => ['type' => 'text', 'label' => 'Last Name:']
],
[
'submit' => ['type' => 'submit', 'value' => 'Save']
]
]);
Fields stay addressable by name regardless of which fieldset holds them, so setFieldValues(), isValid()
and getAllErrors() are unaffected by the grouping. getFieldsets() returns the Pop\Form\Fieldset objects
if you need one directly, and setLegend() changes a legend after the fact.
The container is the markup inside a fieldset and the second argument to either factory. dl is the default
— <dt> for the label, <dd> for the field. table produces a row per field, and anything else wraps each
field in that element.
$form = Pop\Form\Form::createFromConfig($fields, 'div');
For markup the component does not produce, prepareForView() hands back the rendered pieces instead of the
whole form: a {name} entry per field, a {name}_label for each label, and a {name}_errors array for each
field that failed. Merge that into a view's data and lay the fields out in the template yourself — see
Views & Templates.
CSRF Protection#
A csrf field type renders a hidden input holding a cryptographically random token, stores it in the session,
and validates the submitted value against it with a timing-safe comparison:
$form = Pop\Form\Form::createFromConfig([
'csrf_token' => ['type' => 'csrf'],
'username' => ['type' => 'text', 'label' => 'Username:', 'required' => true],
'submit' => ['type' => 'submit', 'value' => 'Register']
]);
There's nothing else to wire up: isValid() covers the token along with every other field, and a wrong one
fails with The security token does not match. A submission missing the field entirely collects both that
message and This field is required., since the element is required by construction.
Tokens are keyed in the session by the field's name, so two CSRF-protected forms on one page each get their
own. A token lives 300 seconds by default — the expire config key changes that, and 0 disables expiry —
and survives re-rendering, so a form redisplayed with errors still carries a token the next submission
matches. $form->clearTokens() drops every stored token.
The element reads $_SERVER['REQUEST_METHOD'] when it is constructed and throws
Error: The server request method is not set. without it, so a csrf field belongs to HTTP forms only, never
to a console command's input.
File Uploads#
A file field takes two options beyond the usual keys, and Form sets enctype="multipart/form-data" on the
<form> tag by itself whenever one is present:
$form = Pop\Form\Form::createFromConfig([
'avatar' => [
'type' => 'file',
'label' => 'Avatar:',
'required' => true,
'allowed-types' => ['jpg', 'jpeg', 'png', 'gif'],
'max-size' => 2000000
],
'submit' => ['type' => 'submit', 'value' => 'Upload']
]);
allowed-types is an extension allowlist and max-size a byte count, each with a human-readable message on
failure.
The file element reads $_FILES itself, so setFieldValues($this->request->getPost()) is enough. It
validates the submitted filename and the size PHP reports rather than the file's content.
Fields from a Database Table#
Fields::getConfigFromTable() derives a field config from a table's schema, which is the fast way to a CRUD
form that stays in step with its table:
$config = Pop\Form\Fields::getConfigFromTable(App\Table\Users::getTableInfo(), null, null, 'id');
$form = Pop\Form\Form::createFromConfig($config);
The fourth argument omits columns — the primary key, almost always. Column types map to element types:
TEXT becomes a textarea, a column named for a password becomes a password field, and everything else
an input.
The second and third arguments take per-field attributes and per-field config overrides, so a generated form
can still carry hand-written validators on the fields that need them. This requires pop-db; see
Records & the ORM for the table classes it reads from.
Validating Without Rendering#
Pop\Form\FormValidator is the validation half on its own, for a JSON endpoint or any request whose markup is
rendered somewhere else entirely:
use Pop\Form\FormValidator;
use Pop\Validator;
$validator = new FormValidator([
'username' => new Validator\AlphaNumeric(),
'password' => new Validator\LengthGreaterThanEqual(8)
]);
$validator->setValues($this->request->getParsedData());
if (!$validator->validate()) {
$this->response->setCode(422);
$this->response->setBody(json_encode(['errors' => $validator->getErrors()]));
$this->response->send();
return;
}
It takes the same validators and produces the same message strings, and getErrors() with no argument returns
the whole field-to-messages map. setRequired() marks fields that must be present, hasErrors($field) tests
one field, and getValues() reads the set back. Filters work here too, through the same addFilter().
For forms whose fields depend on who is looking, Pop\Form\AclForm takes a pop-acl object and hides or
freezes fields a role cannot view or edit — see Authorization.
Forms carry more than this page covers — column layouts, per-element prepend and append content, and the element classes themselves — see the pop-form README.
See Also#
- Requests & Responses — reading the submission, and moving an accepted upload into place
- Views & Templates — rendering a form inside a template
- Controllers — where the submit cycle sits
- Sessions & Cookies — the session a CSRF token is stored in
- Authorization — ACL-aware forms
- pop-form README — the full form and element API
- pop-validator README — every shipped validator