Repository navigation
Forms and Validation
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.
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.
┌───────────────────┐
HTTP → │ HttpRequest │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ FormRequest │ ←─ your subclass (rules(), messages()...)
│ ┌─────────────┐ │
│ │ authorize() │ │
│ │ CSRF check │ │
│ │ prepare() │ │
│ │ validate() │ ─┼──▶ FormValidatorExtended
│ │ uploads() │ ─┼──▶ FileSystemManager
│ └─────────────┘ │
└─────────┬─────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
validated() getErrors() redirectBack()
The request lifecycle inside validate():
-
Authorize —
authorize()must returntrue. -
CSRF — token from input
_token(or header) verified against session/cookie. -
Prepare —
prepareForValidation()can trim/sanitize data. - Validate — each field's rules are executed by the validator.
-
Uploads — on success, file fields are written to disk via
handleUploads(). - Persist — on failure, errors and old input go to the session.
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.
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.
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', []);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_errorsandform_old_inputare cleared automatically.
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"
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:
mimeschecks the client-provided extension, not the file contents. For critical uploads, verify MIME types server-side as well.
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) ?>">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).
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().
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.
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_.
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 everythingNote: merge() only appends to existing entries; it never replaces.
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(); // boolWhen constructing in tests, pass
trueas the fifth argument. The component uses this flag to skip PHP-upload-specific checks where applicable.
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();
}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,
],
];
}
};| 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
|
| 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/maxbehavior note: On numeric values,min/maxcompare the numeric value; otherwise they compare string length. This meansmin:3on an age field requiresage >= 3, while on a name field it requiresstrlen >= 3.
| 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 |
| 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 |
| 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 |
Key methods used internally: getMethod(), getQueryParams(), getPostData(), getFiles(), getJsonBody(), getContentType(), getReferer(), input(string $key), expectsJson().
-
Always check
$form->validate(). Never trust request data without it. -
Use
labels()for user-facing validation messages — the default uses the raw field name. -
Prefer
required+ specific rules. A missingrequiredon an optional field means the rule will silently fail onnull. -
Whitelist uploads. Always pair
imageormimeswithmaxSize— never accept arbitrary uploads. -
Keep
handleUploadsserver-side. File naming should never trustgetClientOriginalName()alone; the sanitizer handles common cases, but add a custom callback if you need checksums or content-derived names. -
Use double-submit CSRF for stateless APIs (
doubleSubmitCsrf = true). -
Prefer
prepareForValidationfor trims — e.g.array_map('trim', $data)— so rules see clean input. -
Don't leak file paths.
validated()returns stored paths; render public URLs in views instead, based on your disk configuration. -
Test with
UploadedFile(..., test: true)and stub the request'sgetFiles()to avoid touching the real filesystem. -
Subclass
FormValidatorExtendedfor domain rules; don't stuff app logic intoFormRequest.
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(_tokenby 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.
Machinjiri Docs
- Home
- Learn
- Build
-
Tutorials
- Routing
- Controllers and Middleware
- Views and Templates
- Database ORM
- Eloquent Database ORM
- Errors and Exceptions
- Forms and Validation
- Http
- Events and Logging
- Services and DI
- Security
- Authentication
- Caching
- File System
- Date and Time
- Debugging
- Queue and Jobs
- Task Scheduling
- Transport (Mail, SMS)
- LDAP
- Notifications
- Webhooks
- UUID Generations
- Terminal
- Helper Function
- Reference