Skip to content
academia.sh

Lesson 08 / 21

Forms and Validation

A validator that works with rule strings is built; the lesson measures that the save step is skipped on invalid input, runs on valid input, and that stopping at the first error skips the next rule.

Published Updated

In this lesson

  • Builds a validator that parses rule strings and a form action that produces error messages
  • Measures that the save step is skipped on invalid input, together with a 422 status
  • Shows as the opposing measurement that the save step runs on valid input
  • Reads how stopping at the first error skips the next rule and affects the number of messages sent to the user
Contents

A template prints every value placed in front of it; it does not check whether the value is in the form it should be. That check is made in the form’s action: if the input coming from the user does not satisfy the rule, it must not reach the save step. But how would you know that it did not reach it? In this lesson the question is what happens to the save step in a request where the rule does not hold.

The measurement axis is the same as in the previous lesson: did the expected part run in this request. The decision order is the classify() function itself: ran, deferred, skipped, not wired. This lesson’s outcome is skipped: the save step is placed on the action’s path, but it is not called. A skipped step shows to the client as a status code; here that code is 422.

VD4. Form input is an array decoded from the request body; the HTTP layer is not built in this lesson, and the action takes an array directly. VD5. A rule string consists of rules separated by |, and a rule’s argument is given with : (min:3). VD6. Invalid input is shown with an empty ('') or short ('ab') field; the logic of the rules is independent of special characters. VD7. Error messages are written in ASCII so that column widths do not break.

The measurement setup comes unchanged.

<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}

Building the Validator

A validator parses the rule string for each field, runs each rule in order, and collects a message for the ones that fail. Two details matter for the measurement. First, all the rules of a field are placed on the path before any rule runs; so a rule that does not run can fall into the skipped outcome. Second, the option in the constructor determines stopping at the first error: if it stops, the later rules of the same field are not called.

The form action calls the validator. If the input is not valid, 422 is returned, and the error messages and the old input are put in the response; the old input keeps the fields from being emptied when the form is shown again. If it is valid, the save step runs and 201 is returned. The save step is placed on the action’s path when the action starts, and its call is made only after validation passes.

<?php
// validation.php — a validator with rule strings and an action that saves a post
declare(strict_types=1);
require_once 'ran.php';

final class Validator {
    public array $errors = [];

    public function __construct(private bool $stopOnFirstError = true) {}

    public function validate(array $input, array $rules): bool {
        $this->errors = [];
        foreach ($rules as $field => $spec) {
            $list = explode('|', $spec);
            foreach ($list as $rule) {
                Ledger::onPath("rule:$field:" . explode(':', $rule)[0]);
            }
            foreach ($list as $rule) {
                if ($this->stopOnFirstError && isset($this->errors[$field])) break;
                [$name, $argument] = array_pad(explode(':', $rule, 2), 2, null);
                Ledger::ran("rule:$field:$name");
                $value = (string) ($input[$field] ?? '');
                $passed = match ($name) {
                    'required' => $value !== '',
                    'min' => mb_strlen($value) >= (int) $argument,
                };
                if (!$passed) {
                    $this->errors[$field][] = match ($name) {
                        'required' => "$field is required",
                        'min' => "$field must be at least $argument characters",
                    };
                }
            }
        }
        return $this->errors === [];
    }
}

function savePost(array $input, Validator $validator, array $rules): array {
    Ledger::onPath('save');
    if (!$validator->validate($input, $rules)) {
        return [422, ['errors' => $validator->errors, 'old' => $input]];
    }
    Ledger::ran('save');
    return [201, ['saved' => $input['title']]];
}

function printResponse(array $response): void {
    [$status, $body] = $response;
    echo "status $status\n";
    foreach ($body['errors'] ?? [] as $field => $messages) {
        foreach ($messages as $message) echo "  error $field: $message\n";
    }
    foreach ($body['old'] ?? [] as $field => $value) echo "  old $field='$value'\n";
    if (isset($body['saved'])) echo "  saved {$body['saved']}\n";
}

The Save Step on Invalid Input

The measurement calls the action with an empty title and a two-letter summary. The rules are: title required, summary at least three characters. Expected: status 422, two error messages, the old input in the response, and the save step skipped. The rule parts are both ran, because both ran and both failed to hold.

<?php
// invalid.php — on invalid input the save step is skipped
require_once 'validation.php';

$rules = ['title' => 'required', 'summary' => 'min:3'];
Ledger::newRequest();
printResponse(savePost(['title' => '', 'summary' => 'ab'], new Validator(), $rules));
foreach (['rule:title:required', 'rule:summary:min', 'save'] as $part) {
    printf("  %-22s %s\n", $part, classify($part));
}
status 422
  error title: title is required
  error summary: summary must be at least 3 characters
  old title=''
  old summary='ab'
  rule:title:required    ran
  rule:summary:min       ran
  save                   skipped

Although two error messages went out, the save step is skipped: it was placed on the action’s path and not called. This skipping is not silent; the client receives 422 and sees which field failed and why. A rule that ran and a rule that did not hold are different things: the measurement axis is whether the rule ran, not whether it passed. The error message is a result of the rule having run.

The Opposing Measurement: Valid Input

The same action is called with valid input. Expected: status 201, no errors, and the save step ran.

<?php
// valid.php — on valid input the save step runs
require_once 'validation.php';

$rules = ['title' => 'required', 'summary' => 'min:3'];
Ledger::newRequest();
printResponse(savePost(['title' => 'First post', 'summary' => 'abc'], new Validator(), $rules));
foreach (['rule:title:required', 'rule:summary:min', 'save'] as $part) {
    printf("  %-22s %s\n", $part, classify($part));
}
status 201
  saved First post
  rule:title:required    ran
  rule:summary:min       ran
  save                   ran

Validation itself ran in the same way in both requests; only the fate of the step behind it changed. The save step is not a gate independent of validation; it is a branch that depends on the result of validation.

The Bounding Measurement

The option in the validator’s constructor determines how many messages go to the user on the same input. Let the summary field have two rules (required|min:3) and let the input be empty. In the validator that does not stop at the first error, both rules run and two messages are collected. In the validator that stops at the first error, required fails, min is not called, and a single message is collected.

<?php
// stopping.php — stopping at the first error makes the next rule skipped
require_once 'validation.php';

$rules = ['title' => 'required', 'summary' => 'required|min:3'];
foreach ([true, false] as $stop) {
    Ledger::newRequest();
    echo $stop ? "stops at first error\n" : "does not stop\n";
    printResponse(savePost(['title' => 'First post', 'summary' => ''], new Validator($stop), $rules));
    foreach (['rule:summary:required', 'rule:summary:min', 'save'] as $part) {
        printf("  %-22s %s\n", $part, classify($part));
    }
}
stops at first error
status 422
  error summary: summary is required
  old title='First post'
  old summary=''
  rule:summary:required  ran
  rule:summary:min       skipped
  save                   skipped
does not stop
status 422
  error summary: summary is required
  error summary: summary must be at least 3 characters
  old title='First post'
  old summary=''
  rule:summary:required  ran
  rule:summary:min       ran
  save                   skipped

In the validator that stops, the min rule is skipped, and in the one that does not stop it ran; the status of the response is 422 in both cases and the save step is skipped in both. Whether the rules hold or not determines the fate of the save step, while the choice to stop determines the number of messages. Telling an empty field “at least three characters” as well gives the user no new information; this is why stopping at the first error is a common choice. But a form that wants to show all the problems of a field in one round builds the second option. The choice is a design decision, and it is visible in the response.

Summary

  • The validator parses rule strings, collects error messages and returns the old input to the form.
  • This lesson’s outcome is skipped: on invalid input the save step was not called although it had been placed on the action’s path; the client received 422 and the messages.
  • In the opposing measurement, valid input brought the save step to the ran outcome and the status became 201.
  • A rule running (ran) is separate from a rule passing; a rule that does not hold has also run.
  • Stopping at the first error makes the next rule in the same field skipped and reduces the number of messages sent to the user; the fate of the save step does not change.

Next Step

Valid input reached the save step. When the save goes through a model, what else runs while it is saved? The next lesson, Object–Table Binding, covers object–relational mapping and measures which part ran while the model was saved.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close