Validation Guide
Last Updated: 2026-01-08 Status: Active Audience: Developers
This guide covers the validation system for the Client Portal application.
Table of Contents
- Overview
- Form Requests
- Common Validation Rules
- Custom Validation Rules
- Error Handling
- API Validation
- File Validation
Overview
All input validation uses Laravel's Form Request classes. This provides:
- Type-safe validation with clear rules
- Authorization checks in the same class
- Consistent error formats
- Reusable validation logic
Form Requests
Creating Form Requests
php artisan make:request StoreClientRequest
php artisan make:request UpdateClientRequest
Basic Structure
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreClientRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()->isAdmin();
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'company_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:clients,email'],
'phone' => ['nullable', 'string', 'max:20'],
'address' => ['nullable', 'string', 'max:500'],
];
}
/**
* Get custom error messages.
*/
public function messages(): array
{
return [
'company_name.required' => 'Please enter the company name.',
'email.unique' => 'A client with this email already exists.',
];
}
/**
* Get custom attribute names.
*/
public function attributes(): array
{
return [
'company_name' => 'company name',
'email' => 'email address',
];
}
}
Update Request (Ignoring Current Record)
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateClientRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isAdmin();
}
public function rules(): array
{
return [
'company_name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'email',
// Ignore current client when checking uniqueness
Rule::unique('clients', 'email')->ignore($this->route('client')),
],
'phone' => ['nullable', 'string', 'max:20'],
'is_active' => ['boolean'],
];
}
}
Using Form Requests
class ClientController extends Controller
{
public function store(StoreClientRequest $request)
{
// Validation already passed, request is authorized
$validated = $request->validated();
$client = Client::create($validated);
return redirect()->route('admin.clients.show', $client);
}
public function update(UpdateClientRequest $request, Client $client)
{
$client->update($request->validated());
return redirect()->route('admin.clients.show', $client);
}
}
Preparing Input Before Validation
class StoreClientRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$this->merge([
'email' => strtolower(trim($this->email)),
'phone' => $this->phone ? preg_replace('/[^0-9+]/', '', $this->phone) : null,
]);
}
}
After Validation Hook
class StoreInvoiceRequest extends FormRequest
{
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1'],
'items.*.description' => ['required', 'string'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if ($this->calculateTotal() <= 0) {
$validator->errors()->add('total', 'Invoice total must be greater than zero.');
}
});
}
private function calculateTotal(): float
{
return collect($this->items)->sum(function ($item) {
return $item['quantity'] * $item['unit_price'];
});
}
}
Common Validation Rules
String Validation
'name' => ['required', 'string', 'min:2', 'max:255'],
'slug' => ['required', 'string', 'alpha_dash', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
Email Validation
// Basic email
'email' => ['required', 'email'],
// Strict RFC + DNS validation
'email' => ['required', 'email:rfc,dns'],
// Unique email
'email' => ['required', 'email', 'unique:users,email'],
Password Validation
use Illuminate\Validation\Rules\Password;
// Default password rules
'password' => ['required', 'confirmed', Password::defaults()],
// Custom password rules
'password' => [
'required',
'confirmed',
Password::min(8)
->mixedCase()
->numbers()
->symbols()
->uncompromised(),
],
// Set defaults in AppServiceProvider
public function boot(): void
{
Password::defaults(function () {
return Password::min(8)
->mixedCase()
->numbers()
->uncompromised();
});
}
Numeric Validation
// Integer
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
// Decimal/float
'price' => ['required', 'numeric', 'min:0', 'max:999999.99'],
// Between
'rating' => ['required', 'integer', 'between:1,5'],
Date Validation
// Basic date
'due_date' => ['required', 'date'],
// After today
'due_date' => ['required', 'date', 'after:today'],
// After another field
'end_date' => ['required', 'date', 'after:start_date'],
// Date format
'birth_date' => ['required', 'date_format:Y-m-d'],
Enum Validation
use App\Enums\InvoiceStatus;
use Illuminate\Validation\Rule;
'status' => ['required', Rule::enum(InvoiceStatus::class)],
Foreign Key Validation
// Check record exists
'client_id' => ['required', 'exists:clients,id'],
// Check exists with additional conditions
'project_id' => [
'required',
Rule::exists('projects', 'id')->where('client_id', $this->client_id),
],
Conditional Validation
public function rules(): array
{
$rules = [
'name' => ['required', 'string'],
'type' => ['required', 'in:individual,company'],
];
// Add company-specific rules
if ($this->type === 'company') {
$rules['company_name'] = ['required', 'string', 'max:255'];
$rules['tax_id'] = ['required', 'string', 'max:50'];
}
return $rules;
}
// Or using sometimes
public function rules(): array
{
return [
'name' => ['required', 'string'],
'type' => ['required', 'in:individual,company'],
'company_name' => ['sometimes', 'required_if:type,company', 'string'],
];
}
Array Validation
// Basic array
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:50'],
// Array of objects
'items' => ['required', 'array', 'min:1', 'max:50'],
'items.*.description' => ['required', 'string', 'max:255'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
Custom Validation Rules
Creating Custom Rules
php artisan make:rule ValidPhoneNumber
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidPhoneNumber implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Remove non-numeric characters except +
$cleaned = preg_replace('/[^0-9+]/', '', $value);
// Must be 10-15 digits
if (!preg_match('/^\+?[0-9]{10,15}$/', $cleaned)) {
$fail('The :attribute must be a valid phone number.');
}
}
}
Using Custom Rules
use App\Rules\ValidPhoneNumber;
'phone' => ['nullable', new ValidPhoneNumber],
Inline Custom Rules
'invoice_number' => [
'required',
function (string $attribute, mixed $value, Closure $fail) {
if (!str_starts_with($value, 'INV-')) {
$fail("The {$attribute} must start with 'INV-'.");
}
},
],
Error Handling
Displaying Errors in Blade
{{-- All errors --}}
@if ($errors->any())
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
{{-- Single field error --}}
<input type="text" name="email" value="{{ old('email') }}"
class="@error('email') border-red-500 @enderror">
@error('email')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
{{-- Using error component --}}
<x-input-error :messages="$errors->get('email')" class="mt-2" />
Old Input
<input type="text" name="company_name" value="{{ old('company_name', $client->company_name ?? '') }}">
Named Error Bags
// In controller
return back()->withErrors($validator, 'login');
// In Blade
@error('email', 'login')
<p class="text-red-500">{{ $message }}</p>
@enderror
API Validation
JSON Responses
Form Requests automatically return JSON for API requests:
{
"message": "The given data was invalid.",
"errors": {
"email": [
"The email field is required."
],
"company_name": [
"The company name must be at least 2 characters."
]
}
}
Custom API Form Request
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class StoreClientRequest extends FormRequest
{
public function authorize(): bool
{
return true; // API auth handled by middleware
}
public function rules(): array
{
return [
'company_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:clients,email'],
];
}
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $validator->errors(),
], 422));
}
}
Manual Validation in API
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'company_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:clients'],
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors(),
], 422);
}
$client = Client::create($validator->validated());
return response()->json([
'success' => true,
'data' => $client,
], 201);
}
File Validation
Basic File Validation
'document' => ['required', 'file', 'max:10240'], // 10MB max
'image' => ['required', 'image', 'max:2048'], // 2MB max image
MIME Type Validation
// By extension
'document' => ['required', 'file', 'mimes:pdf,doc,docx'],
// By MIME type
'document' => ['required', 'file', 'mimetypes:application/pdf,application/msword'],
Image Dimensions
use Illuminate\Validation\Rule;
'avatar' => [
'required',
'image',
Rule::dimensions()
->minWidth(100)
->minHeight(100)
->maxWidth(2000)
->maxHeight(2000),
],
// Square images only
'icon' => [
'required',
'image',
Rule::dimensions()->ratio(1/1),
],
Multiple Files
'files' => ['required', 'array', 'max:10'],
'files.*' => ['file', 'mimes:pdf,doc,docx,jpg,png', 'max:10240'],
File Upload Form Request
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UploadProjectFileRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('uploadFile', $this->route('project'));
}
public function rules(): array
{
return [
'file' => [
'required',
'file',
'mimes:pdf,doc,docx,xls,xlsx,jpg,jpeg,png,gif',
'max:' . config('client_portal.max_file_size', 10240),
],
'description' => ['nullable', 'string', 'max:500'],
'is_client_visible' => ['boolean'],
];
}
public function messages(): array
{
$maxSize = config('client_portal.max_file_size', 10240) / 1024;
return [
'file.max' => "The file must not be larger than {$maxSize}MB.",
'file.mimes' => 'Only PDF, Word, Excel, and image files are allowed.',
];
}
}
Best Practices
Do
- Use Form Request classes for all validation
- Keep validation rules close to usage
- Use custom error messages for better UX
- Validate at the boundary (controller/API entry point)
- Use
validated()to get only validated data - Sanitize input in
prepareForValidation()
Don't
- Validate in multiple places (avoid duplication)
- Trust any user input without validation
- Use overly permissive rules (e.g.,
'file' => ['file']) - Forget to validate array items
- Hard-code error messages (use
messages()method)