Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Development Guides/Validation Guide

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

  1. Overview
  2. Form Requests
  3. Common Validation Rules
  4. Custom Validation Rules
  5. Error Handling
  6. API Validation
  7. 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)