Skip to content

Forms and Validation

Precious Lyson edited this page Nov 13, 2026 · 2 revisions

The Machinjiri Forms Component is a complete form-processing toolkit that sits between your HTTP layer and your application logic. It bundles:

Class Responsibility
FormRequest Abstract request handler — orchestrates validation, CSRF, uploads
FormValidator / FormValidatorExtended The validation engine (with file rules in the extended version)
RuleBuilder Fluent API for declaring rules inline
FormErrorBag Collects, merges, retrieves and session-persists errors
FileUpload Standalone, configurable file upload handler
UploadedFile Safe value object wrapping a PHP upload array

Why use it?

  • Declarative — define rules as a simple associative array.
  • Safe — validates CSRF by default, whitelists extensions, size-limits uploads.
  • Stateful — automatically flashes errors and old input to the session.
  • Flexible — works with HTML forms, JSON APIs, and file uploads.
  • Pluggable filesystem — local or FTP disks via FileSystemManager.

Installation

Ensure the component and its dependencies are autoloaded. The namespace root is:

Mlangeni\Machinjiri\Core\Forms
Mlangeni\Machinjiri\Core\Http
Mlangeni\Machinjiri\Core\FileSystem
Mlangeni\Machinjiri\Core\Authentication
Mlangeni\Machinjiri\Core\Security\Tokens
Mlangeni\Machinjiri\Core\Exceptions

The component relies on a Container for dependency resolution. Instances of HttpRequest, HttpResponse, Session, CSRFToken, and FileSystemManager must be registered in your service container.


Architecture Overview

          ┌───────────────────┐
 HTTP  →  │   HttpRequest     │
          └─────────┬─────────┘
                    │
                    ▼
          ┌───────────────────┐
          │   FormRequest     │ ←─ your subclass (rules(), messages()...)
          │  ┌─────────────┐  │
          │  │ authorize() │  │
          │  │ CSRF check  │  │
          │  │ prepare()   │  │
          │  │ validate()  │ ─┼──▶ FormValidatorExtended
          │  │ uploads()   │ ─┼──▶ FileSystemManager
          │  └─────────────┘  │
          └─────────┬─────────┘
                    │
        ┌───────────┼───────────┐
        ▼           ▼           ▼
   validated()  getErrors()  redirectBack()

The request lifecycle inside validate():

  1. Authorize — authorize() must return true.
  2. CSRF — token from input _token (or header) verified against session/cookie.
  3. Prepare — prepareForValidation() can trim/sanitize data.
  4. Validate — each field's rules are executed by the validator.
  5. Uploads — on success, file fields are written to disk via handleUploads().
  6. Persist — on failure, errors and old input go to the session.

Beginner Guide

Your First FormRequest

Create a subclass in your application:

<?php
namespace App\Http\Requests;

use Mlangeni\Machinjiri\Core\Forms\FormRequest;

class ContactRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'    => 'required|min:3|max:100',
            'email'   => 'required|email',
            'message' => 'required|min:10|max:2000',
        ];
    }
}

In your controller:

$form = new ContactRequest();

if (!$form->validate()) {
    return $form->redirectBack();
}

$data = $form->validated();
// ['name' => '...', 'email' => '...', 'message' => '...']

That's it. If validation fails, errors and the raw input are flashed to the session; redirectBack() sends the user back to the form.

Basic Validation Rules

Rules are pipe-separated strings, and each rule accepts optional parameters after a colon:

public function rules(): array
{
    return [
        'username' => 'required|alphaDash|min:3|max:20',
        'age'      => 'required|integer|between:18,120',
        'website'  => 'url',
        'password' => 'required|min:8|confirmed',
        'role'     => 'required|in:admin,editor,viewer',
        'bio'      => 'max:500',
    ];
}

Parameters are comma-separated: between:18,120 passes 18 and 120.

Handling Errors

After a failed validate(), errors live on the FormErrorBag:

$form = new RegisterRequest();

if (!$form->validate()) {
    $errors = $form->getErrors();
    // [
    //   'email' => ['Email must be a valid email address'],
    //   'name'  => ['Name is required'],
    // ]

    $bag = $form->getErrorBag();
    if ($bag->has('email')) {
        echo $bag->get('email'); // first message for 'email'
    }

    return $form->redirectBack();
}

The bag is also written to the session under the key form_errors:

$errors = $session->get('form_errors', []);

Displaying Old Input

On failure, the raw (prepared) input is stored in form_old_input. Load it back into the form handler:

$form = (new RegisterRequest())->loadOldInput();

// In the controller, pull the old data out for the view:
$old = $session->get('form_old_input', []);

In your template:

<input type="text" name="name" value="<?= htmlspecialchars($old['name'] ?? '') ?>">

<?php if (!empty($errors['name'])): ?>
    <span class="error"><?= htmlspecialchars($errors['name'][0]) ?></span>
<?php endif; ?>

Note: on success, both form_errors and form_old_input are cleared automatically.


Intermediate Guide

Custom Messages and Labels

labels() gives fields friendly names for error messages. messages() overrides specific rule messages using field.rule syntax (or _form for a generic message):

public function labels(): array
{
    return [
        'name'  => 'Full name',
        'email' => 'Email address',
    ];
}

public function messages(): array
{
    return [
        'name.required'  => 'Please tell us your name.',
        'email.email'    => 'That does not look like an email.',
        'password.min'   => 'Choose a password of at least 8 characters.',
        '_form'          => 'There was a problem with your submission.',
    ];
}

The default messages are inferred from the label:

  • name.required → "Full name is required"
  • email.email → "Email address must be a valid email address"

File Uploads

Include a file, image, mimes, maxSize, minSize, or dimensions rule to make the field a file upload:

class UploadAvatarRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'avatar' => 'required|image|maxSize:2048|dimensions:200,200',
            'resume' => 'file|mimes:pdf,doc,docx|maxSize:5120',
        ];
    }

    public function uploadDisk(): string
    {
        return 'local';         // or 'ftp'
    }

    public function uploadDirectory(): string
    {
        return 'avatars';       // under the disk root
    }
}

After validate() succeeds, validated()['avatar'] is the stored path (a string), not the UploadedFile object:

$paths = $form->validated();
// ['avatar' => 'avatars/jane_doe.png', 'resume' => 'resume/jane_doe.pdf']

How uploads are handled in FormRequest

  • Only fields whose rule set contains a file rule are treated as uploads.
  • The original filename is sanitized: non [a-zA-Z0-9_.-] characters become _.
  • Existing files are not overwritten — a numeric suffix is appended (name_1.png, name_2.png, ...).
  • Files are written with visibility: private.

Available file rules

Rule Example Purpose
file file Must be a successfully uploaded file
image image MIME type starts with image/
mimes mimes:png,jpg,pdf Extension whitelist
maxSize maxSize:2048 Maximum size in KB
minSize minSize:10 Minimum size in KB
dimensions dimensions:800,600 Exact pixel dimensions

Security note: mimes checks the client-provided extension, not the file contents. For critical uploads, verify MIME types server-side as well.

CSRF Protection

CSRF validation is on by default. FormRequest reads the token from the input key named in $csrfTokenName (default _token).

Session-based (default): The token is validated via CSRFToken::validateToken().

Double-submit cookie mode: Override the property in your subclass:

class LoginRequest extends FormRequest
{
    protected bool $doubleSubmitCsrf = true;
    protected string $csrfTokenName  = '_csrf';
}

In double-submit mode, validateTokenWithCookie() is used.

Generating a token for a form (e.g. in your template builder):

$token = FormRequest::generateCsrfToken($csrfToken);
// or for double-submit:
$token = FormRequest::generateCsrfToken($csrfToken, true);
<input type="hidden" name="_token" value="<?= htmlspecialchars($token) ?>">

AJAX / JSON Responses

respondWithErrors() produces a 422 JSON body for fetch/AJAX handlers:

if (!$form->validate()) {
    if ($request->expectsJson()) {
        return $form->respondWithErrors()->send();
    }
    return $form->redirectBack();
}

Response body:

{
    "success": false,
    "errors": {
        "email": ["Email must be a valid email address"]
    },
    "message": "Validation failed"
}

You can pick a different status code via the first argument: respondWithErrors(400).


Advanced Guide

Custom Validation Rules

You can pass a callable as a rule. The callable receives the field value (plus any extra params) and must return true on success or a string error message on failure:

public function rules(): array
{
    return [
        'coupon' => [
            function ($value) {
                if (!preg_match('/^[A-Z]{2}\d{4}$/', (string) $value)) {
                    return 'Coupon must look like AB1234.';
                }
                return true;
            }
        ],
    ];
}

Wait — the current rules() signature returns array<string,string>. To use callables cleanly, build them via addRule() from the validator or use a custom() rule builder (see below). The engine does support is_callable($rule) inside FormValidator::validate().

The RuleBuilder Fluent API

For programmatic rule construction (useful when rules are built dynamically), use FormValidator::field():

use Mlangeni\Machinjiri\Core\Forms\FormValidatorExtended;

$validator = new FormValidatorExtended();
$validator->setData($request->all());

$validator->field('email')->required()->email();
$validator->field('password')->required()->min(8)->confirmed();
$validator->field('age')->integer()->between(18, 120);
$validator->field('avatar')->image()->maxSize(2048);

// Custom callback with a custom message
$validator->field('username')->custom(
    fn ($v) => preg_match('/^[a-z0-9_]+$/', (string) $v) === 1,
    'Username may only contain lowercase letters, numbers and underscores.'
);

if (!$validator->validate()) {
    print_r($validator->getErrors());
}

$data = $validator->getValidatedData();

The builder maps any method call to addRule($field, $method, ...$args), so any rule method on the validator is available.

Standalone FileUpload Class

If you don't need the full FormRequest pipeline, use FileUpload directly:

use Mlangeni\Machinjiri\Core\Forms\FileUpload;
use Mlangeni\Machinjiri\Core\Forms\UploadedFile;

$file = new UploadedFile(
    $_FILES['avatar']['tmp_name'],
    $_FILES['avatar']['name'],
    $_FILES['avatar']['type'],
    $_FILES['avatar']['error']
);

$uploader = (new FileUpload($fileSystem, 'local', 'uploads/avatars'))
    ->allowExtensions(['png', 'jpg', 'jpeg', 'webp'])
    ->maxSize(1024)                 // KB
    ->overwrite(false)
    ->useRandomName();              // default

try {
    $path = $uploader->upload($file); // 'uploads/avatars/3f1a...e9.png'
} catch (MachinjiriException $e) {
    // handle: bad extension, too large, upload error, file exists
}

Naming strategies

$uploader->useOriginalName();  // 'my photo.png' → 'my_photo.png'
$uploader->useRandomName();    // bin2hex(random_bytes(16)) . '.ext'
$uploader->useCustomCallback(fn (UploadedFile $f, string $ext) => 
    'user-' . time() . '.' . $ext
);

useOriginalName() sanitizes the filename: anything outside [a-zA-Z0-9_-] becomes _.

FormErrorBag Deep Dive

The error bag is a flat map of field => string[] messages:

$bag = new FormErrorBag();

$bag->add('email', 'Email is required');
$bag->add('email', 'Email must be unique');
$bag->add('password', 'Password is too short');

$bag->has();            // true (any errors)
$bag->has('email');     // true
$bag->has('name');      // false

$bag->get('email');     // 'Email is required'  (first message)
$bag->all();            // ['email' => [...], 'password' => [...]]

$bag->merge([
    'name' => 'Name is required',      // string is cast to array
    'email' => ['Another error'],      // merged with existing
]);
// ['email' => ['Email is required', 'Email must be unique', 'Another error'], ...]

$bag->toSession($session, 'form_errors');
$bag->clear();          // resets everything

Note: merge() only appends to existing entries; it never replaces.

UploadedFile Object

A minimal, safe value object over the PHP upload array:

$f = new UploadedFile(
    $_FILES['doc']['tmp_name'],
    $_FILES['doc']['name'],
    $_FILES['doc']['type'],
    $_FILES['doc']['error'],
    false // $test — pass true when constructing from a test harness
);

$f->getTmpName();                // '/tmp/phpXXXX'
$f->getClientOriginalName();     // 'report.pdf'
$f->getClientOriginalExtension();// 'pdf'  (lowercase not enforced)
$f->getClientMimeType();         // 'application/pdf'
$f->getError();                  // UPLOAD_ERR_OK (0)
$f->getSize();                   // bytes; throws RuntimeException if unreadable
$f->isTest();                    // bool

When constructing in tests, pass true as the fifth argument. The component uses this flag to skip PHP-upload-specific checks where applicable.

Extending FormValidatorExtended

FormValidatorExtended adds file rules on top of FormValidator. To add domain rules, subclass it:

<?php
namespace App\Validation;

use Mlangeni\Machinjiri\Core\Forms\FormValidatorExtended;

class AppValidator extends FormValidatorExtended
{
    protected function validateSlug($value): bool
    {
        return preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', (string) $value) === 1;
    }

    protected function validatePhone($value): bool
    {
        return preg_match('/^\+?[1-9]\d{6,14}$/', (string) $value) === 1;
    }

    protected function validateStrongPassword($value): bool
    {
        return strlen($value) >= 12
            && preg_match('/[A-Z]/', $value)
            && preg_match('/[a-z]/', $value)
            && preg_match('/\d/', $value)
            && preg_match('/[^A-Za-z0-9]/', $value);
    }

    protected function getDefaultMessage(string $label, string $rule, array $params): string
    {
        return match ($rule) {
            'slug'           => "{$label} must be a lowercase slug (a-z, 0-9, hyphens).",
            'phone'          => "{$label} must be a valid phone number.",
            'strongPassword' => "{$label} must contain at least 12 characters with upper, lower, number and symbol.",
            default          => parent::getDefaultMessage($label, $rule, $params),
        };
    }
}

Then use it in your FormRequest subclass by overriding the constructor:

public function __construct(...$args)
{
    parent::__construct(...$args);
    $this->validator = new AppValidator();
}

Testing Forms

You can construct a FormRequest with injected dependencies, making it fully testable:

use Mlangeni\Machinjiri\Core\Http\HttpRequest;
use Mlangeni\Machinjiri\Core\Forms\UploadedFile;

$request = new HttpRequest(
    method: 'POST',
    uri: '/register',
    postData: ['name' => 'Jane', 'email' => 'jane@example.com'],
    cookies: [],
    server: ['REMOTE_ADDR' => '127.0.0.1'],
    headers: ['Content-Type' => 'application/x-www-form-urlencoded'],
    body: ''
);

$form = new RegisterRequest($request);

For files, construct an UploadedFile with $test = true and inject it via the request's file bag. Because the current HttpRequest reads $_FILES into $this->files in the constructor, you'll typically stub getFiles() on a test double:

$request = new class('POST', '/upload') extends HttpRequest {
    public function getFiles(): array
    {
        return [
            'avatar' => [
                'tmp_name' => __DIR__ . '/fixtures/avatar.png',
                'name'     => 'avatar.png',
                'type'     => 'image/png',
                'error'    => UPLOAD_ERR_OK,
            ],
        ];
    }
};

Validation Rule Reference

Scalar rules (available in FormValidator)

Rule Params Description
required — Value not empty (accepts '0' / 0)
email — FILTER_VALIDATE_EMAIL
min n Numeric ≥ n; string length ≥ n
max n Numeric ≤ n; string length ≤ n
numeric — is_numeric
integer — FILTER_VALIDATE_INT
float — FILTER_VALIDATE_FLOAT
url — FILTER_VALIDATE_URL
ip — FILTER_VALIDATE_IP
date — strtotime() !== false
regex pattern preg_match
in a,b,c Value in the list
notIn a,b,c Value not in the list
same otherField Equal to another field
different otherField Not equal to another field
between min,max Numeric range
digits n Numeric with exactly n digits
digitsBetween min,max Numeric digit length range
alpha — ctype_alpha
alphaNum — ctype_alnum
alphaDash — [a-zA-Z0-9_-]+
confirmed — Matches {field}_confirmation

File rules (added in FormValidatorExtended)

Rule Params Description
file — Valid UploadedFile with UPLOAD_ERR_OK
image — MIME type begins with image/
mimes ext,ext,... Extension whitelist
maxSize kb Maximum size in KB
minSize kb Minimum size in KB
dimensions w,h Exact pixel width/height

min / max behavior note: On numeric values, min/max compare the numeric value; otherwise they compare string length. This means min:3 on an age field requires age >= 3, while on a name field it requires strlen >= 3.


API Reference

FormRequest

Method Description
abstract rules(): array Define field → rules string
messages(): array Field/rule-level message overrides
labels(): array Human-readable field labels
authorize(): bool Gate before validation (default true)
uploadDisk(): string Disk name for uploads (default local)
uploadDirectory(): string Upload subdirectory (default uploads)
validate(): bool Run the full pipeline
validated(): array Get validated (and post-upload) data
getErrors(): array All errors as field => string[]
getErrorBag(): FormErrorBag The error bag instance
redirectBack(): HttpResponse Redirect to referer (or /)
respondWithErrors(int $status = 422): HttpResponse JSON error response
loadOldInput(): self Load old input from session
protected prepareForValidation(array): array Hook to sanitize input
protected handleUploads(array): array Hook to customise storage
static generateCsrfToken(CSRFToken, bool $double = false): string Issue a token

FormValidator / FormValidatorExtended

Method Description
setData(array): self Set input data
addRule(string $field, $rule, ...$params): self Register a rule
setFieldLabel(string $field, string $label): self Set label
setCustomMessage(string $field, string $rule, string $msg): self Override a message
field(string $field): RuleBuilder Fluent rule builder
validate(): bool Execute rules
getErrors(): array Errors so far
getError(string $field): ?string First error for field
getValidatedData(): array Only fields that passed

FileUpload

Method Description
__construct(FileSystemManager, $disk='local', $baseDir='uploads') Constructor
allowExtensions(array): self Whitelist extensions
maxSize(int $kb): self Set KB limit (default 2048)
overwrite(bool): self Permit overwriting
useOriginalName(): self Filename strategy
useRandomName(): self Filename strategy
useCustomCallback(callable): self fn(UploadedFile, string $ext): string
upload(UploadedFile, ?string $subDir = ''): string Perform upload; returns stored path

HttpRequest (used by FormRequest)

Key methods used internally: getMethod(), getQueryParams(), getPostData(), getFiles(), getJsonBody(), getContentType(), getReferer(), input(string $key), expectsJson().


Best Practices

  1. Always check $form->validate(). Never trust request data without it.
  2. Use labels() for user-facing validation messages — the default uses the raw field name.
  3. Prefer required + specific rules. A missing required on an optional field means the rule will silently fail on null.
  4. Whitelist uploads. Always pair image or mimes with maxSize — never accept arbitrary uploads.
  5. Keep handleUploads server-side. File naming should never trust getClientOriginalName() alone; the sanitizer handles common cases, but add a custom callback if you need checksums or content-derived names.
  6. Use double-submit CSRF for stateless APIs (doubleSubmitCsrf = true).
  7. Prefer prepareForValidation for trims — e.g. array_map('trim', $data) — so rules see clean input.
  8. Don't leak file paths. validated() returns stored paths; render public URLs in views instead, based on your disk configuration.
  9. Test with UploadedFile(..., test: true) and stub the request's getFiles() to avoid touching the real filesystem.
  10. Subclass FormValidatorExtended for domain rules; don't stuff app logic into FormRequest.

Troubleshooting

MachinjiriException: File upload error code: 4 No file was uploaded. This usually means the user didn't select a file. Either mark the field optional (drop required) or check for UPLOAD_ERR_NO_FILE before calling validate().

Errors don't appear after redirect Both form_errors and form_old_input are written to the session on failure. Make sure your view reads them from the same session object, and that the session is not reset before rendering.

Cannot get validated data before validation. You called validated() on a fresh FormRequest or before validate() returned true. Guard with:

if ($form->validate()) {
    $data = $form->validated();
}

CSRF always fails

  • If using session CSRF, ensure the token is regenerated per session and persisted.
  • If using double-submit, verify the cookie name/value pair matches CSRFToken's expected cookie.
  • Confirm the input key matches $csrfTokenName (_token by default).

File size exceeds N KB on a small file maxSize is in kilobytes (not bytes). maxSize:2048 = 2 MB. If your file is ~3 MB, raise the limit.

File 'x' already exists. from FileUpload FileUpload refuses to overwrite by default. Either call ->overwrite(true), use useRandomName(), or use the safe-naming strategy inside FormRequest::handleUploads() (which auto-increments).

Call to undefined method validateSlug() Custom rules require a method named validate<RuleName> on the validator. Remember PHP method names are case-sensitive on the class, but the rule name is looked up via ucfirst() — so slug maps to validateSlug. Also ensure $this->validator is an instance of your subclass.

File uploads ignore my uploadDisk() Some containers resolve FileSystemManager lazily. If the disk hasn't been registered in your service provider, disk('ftp') will throw when handleUploads() runs. Register the disk in the boot process.


Clone this wiki locally