Forms

One declaration that renders a form, validates it, and carries its own spam protection.

PHP
<?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
<?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');
The markup and the validation cannot disagree

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-describedby pointing at hints and errors, and aria-invalid on 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
<?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
<?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.

Hidden by HTML, not by a stylesheet

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
<?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.

Be clear-eyed about what this stops

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
<?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
<?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 carries novalidate so 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 — LoginThrottle shows the shape.