Forms
One declaration that renders a form, validates it, and carries its own spam protection.
<?php
use PhpOrbit\Form\Field;
use PhpOrbit\Form\Form;
$form = Form::post('/contact')
->add(
Field::text('name')->required()->max(80),
Field::email('email')->required()->hint('Only used to reply to you.'),
Field::select('topic', ['General', 'Bug report', 'Security'])->required(),
Field::textarea('message')->required()->min(10)->max(2000),
)
->submitLabel('Send message');<?php
// Rendering
echo $form->render($session);
// Handling
$submission = $form->handle($request, $session);
if ($submission->failed()) {
return $this->view->respond('contact', [
'form' => $form->render($session, $submission->old(), $submission->errors()),
], Status::UnprocessableEntity);
}
$name = $submission->value('name');Field::email('email')->required() produces the required attribute and the check that rejects a blank value. A form whose rendering and validation are declared separately is the ordinary way a field ends up unvalidated — the two drift, and the one that matters is the one nobody updated.
What you get without asking
- A CSRF token, on every
post()form. Not a line of boilerplate you could forget. - Escaped output. There is no method on a form that emits raw HTML, so a rejected submission's values go back into the page as values rather than markup.
- Labels tied to inputs, with
aria-describedbypointing at hints and errors, andaria-invalidon the fields that failed. - Passwords are never echoed back on redisplay.
- Selects are re-checked server-side against the options they were rendered with — the browser is not trusted to have offered only what it was given.
Fields
<?php
Field::text('name'); Field::email('email'); Field::password('password');
Field::textarea('body'); Field::number('quantity'); Field::url('website');
Field::tel('phone'); Field::date('starts_on'); Field::checkbox('terms');
Field::select('topic', ['General', 'Support']);
Field::text('name')
->label('Your full name') // otherwise derived from the field name
->required()
->min(2)->max(80)
->hint('As it appears on your account')
->placeholder('Ada Lovelace')
->autocomplete('name');Fields and forms are immutable, so a form may be defined once — at boot, or in a small class of its own — and rendered on every request. Nothing about a visitor sticks to it.
Honeypot
Two cheap checks that ask a person for nothing.
<?php
use PhpOrbit\Form\Honeypot;
$form = $form->protectWith(new Honeypot($signer));A decoy field a person never sees and a script fills because it fills everything. A signed timestamp saying when the form was rendered: submissions arriving faster than a person could type are refused, and ones arriving hours later have gone stale. Signed rather than stored, so it costs no session state and survives a visitor with several tabs open.
The decoy sits in a <div hidden aria-hidden="true">. This framework ships no inline CSS, and a class whose rule someone forgets to copy into their own stylesheet would leave the trap visible — and then reject the real people who dutifully fill it in. The hidden attribute is honoured by the browser's own stylesheet, so it works wherever the markup does.
Captcha
<?php
use PhpOrbit\Form\MathCaptcha;
$form = $form->withCaptcha(new MathCaptcha($encrypter));A small arithmetic question — What is seven plus 3? — with some numbers spelled as words to defeat the obvious regex. No JavaScript, no third-party script, no images, no request leaving your server, and nothing for a screen reader to struggle with. A distorted-image captcha fails all five.
The answer is encrypted, not signed. A signed value is still readable, and the visitor could simply read the answer out of the page source. It is also bound to the session, so a challenge solved elsewhere — by a human solving service, say — cannot be pasted into another visitor's submission, and it expires.
It stops undirected scripts: the ones that post to every form they find. It will not stop someone who has decided to attack you specifically, because a language model solves arithmetic without effort. Treat it as one layer alongside the honeypot and rate limiting, not as a wall.
If you need to resist a determined attacker, implement the Captcha interface against a service built for that job — and note that most of them need JavaScript, which is a choice for your application to make.
What a rejected submission is told
<?php
if ($submission->looksAutomated()) {
$logger->log(Level::Warning, 'form rejected', ['reason' => $submission->rejectedAs]);
}The page gets one generic message. The reason — the decoy field was filled in, submitted after 0 seconds — goes to your log only. Telling the submitter which check fired tells a script author exactly what to change next.
values() throws if the submission was rejected, so there is no path that reads a field the checks refused. Use old() to repopulate the form and errors() to show what went wrong.
Defining a form once
Rendering and handling usually live in different controllers. Give the form its own small class so the two cannot disagree:
<?php
final class ContactForm
{
public function __construct(
private readonly Signer $signer,
private readonly Encrypter $encrypter,
) {
}
public function build(): Form
{
return Form::post('/contact')
->add(/* … */)
->protectWith(new Honeypot($this->signer))
->withCaptcha(new MathCaptcha($this->encrypter));
}
}The demo application does exactly this — see /contact on a running server, and read the page source: the decoy is visible in the markup, and the captcha's sealed answer is not.
orbit make:form Contact --controllers writes that class, the two controllers built against it and the template, with the honeypot already attached — see The orbit CLI.
Not built
- File inputs. Uploads have their own quotas and cleanup contract; see File uploads.
- Radio groups and multi-selects. A select covers the common case; anything richer is markup you write yourself around the same
Validator. - Client-side validation. The rendered attributes (
required,maxlength,type) are what the browser needs; the form carriesnovalidateso your messages are shown rather than the browser's, and every rule is enforced on the server regardless. - Rate limiting. Worth adding in front of any public form —
LoginThrottleshows the shape.