Navigation

Laravel

Custom Laravel Validation Rules: Beyond The Basics

Master advanced Laravel validation with custom rules, complex business logic validation, and performance optimization. Learn to build robust validation for enterprise applications with practical examples and testing strategies.

Master advanced Laravel validation techniques with custom rules, conditional validation, and complex business logic validation for enterprise applications.

Table Of Contents

Understanding Custom Validation Rules

Laravel's built-in validation rules cover most common scenarios, but real-world applications often require custom validation logic that reflects specific business requirements. Custom validation rules provide a clean, reusable way to encapsulate complex validation logic while maintaining the elegant syntax Laravel developers love.

Custom rules shine when you need to validate against external APIs, implement complex business rules, or create domain-specific validation that goes beyond basic data type checking. This is particularly important when building SaaS applications with Laravel where business logic validation is crucial.

Creating Basic Custom Rules

Rule Objects

The modern way to create custom validation rules uses dedicated rule classes:

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use App\Services\DomainValidationService;

class ValidDomain implements Rule
{
    protected string $message;
    protected DomainValidationService $domainService;
    
    public function __construct(DomainValidationService $domainService)
    {
        $this->domainService = $domainService;
    }
    
    public function passes($attribute, $value): bool
    {
        if (empty($value)) {
            return true; // Let 'required' rule handle empty values
        }
        
        // Check if domain is valid format
        if (!filter_var($value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
            $this->message = 'The :attribute must be a valid domain name.';
            return false;
        }
        
        // Check if domain is not blacklisted
        if ($this->domainService->isBlacklisted($value)) {
            $this->message = 'The :attribute domain is not allowed.';
            return false;
        }
        
        // Check if domain resolves (for stricter validation)
        if (!$this->domainService->resolves($value)) {
            $this->message = 'The :attribute domain does not exist.';
            return false;
        }
        
        return true;
    }
    
    public function message(): string
    {
        return $this->message;
    }
}

Usage in controllers or form requests:

use App\Rules\ValidDomain;

class CreateWebsiteRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'domain' => ['required', 'string', new ValidDomain(app(DomainValidationService::class))],
            'name' => 'required|string|max:255',
        ];
    }
}

Closure-Based Rules

For simpler validation logic, use closure-based rules:

use Illuminate\Validation\Rule;

public function rules(): array
{
    return [
        'coupon_code' => [
            'required',
            'string',
            function ($attribute, $value, $fail) {
                $coupon = Coupon::where('code', $value)->first();
                
                if (!$coupon) {
                    $fail('The coupon code is invalid.');
                    return;
                }
                
                if ($coupon->is_expired) {
                    $fail('The coupon code has expired.');
                    return;
                }
                
                if ($coupon->usage_count >= $coupon->usage_limit) {
                    $fail('The coupon code has reached its usage limit.');
                    return;
                }
                
                if ($coupon->min_order_amount > $this->input('total_amount', 0)) {
                    $fail("The coupon requires a minimum order of {$coupon->min_order_amount}.");
                }
            }
        ],
    ];
}

Advanced Custom Rule Patterns

Database-Dependent Rules

Create rules that validate against database constraints with caching for performance:

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Cache;
use App\Models\User;

class UniqueUsernameAcrossTenants implements Rule
{
    protected int $tenantId;
    protected ?int $excludeUserId;
    
    public function __construct(int $tenantId, ?int $excludeUserId = null)
    {
        $this->tenantId = $tenantId;
        $this->excludeUserId = $excludeUserId;
    }
    
    public function passes($attribute, $value): bool
    {
        $cacheKey = "username_check_{$this->tenantId}_{$value}_{$this->excludeUserId}";
        
        return Cache::remember($cacheKey, 300, function () use ($value) {
            $query = User::where('tenant_id', $this->tenantId)
                        ->where('username', $value);
            
            if ($this->excludeUserId) {
                $query->where('id', '!=', $this->excludeUserId);
            }
            
            return !$query->exists();
        });
    }
    
    public function message(): string
    {
        return 'The username is already taken within this organization.';
    }
}

API-Based Validation

Validate against external services with proper error handling:

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class ValidPostalCode implements Rule
{
    protected string $country;
    protected string $message = 'The :attribute is not a valid postal code.';
    
    public function __construct(string $country = 'US')
    {
        $this->country = $country;
    }
    
    public function passes($attribute, $value): bool
    {
        try {
            $response = Http::timeout(5)
                          ->retry(2, 1000)
                          ->get("https://api.postal-validation.com/validate", [
                              'postal_code' => $value,
                              'country' => $this->country,
                              'api_key' => config('services.postal_validation.key'),
                          ]);
            
            if ($response->successful()) {
                $data = $response->json();
                
                if (!$data['valid']) {
                    $this->message = $data['message'] ?? 'The :attribute is not a valid postal code.';
                    return false;
                }
                
                return true;
            }
            
            // If API is down, use basic format validation as fallback
            return $this->fallbackValidation($value);
            
        } catch (\Exception $e) {
            Log::warning('Postal code validation API failed', [
                'postal_code' => $value,
                'country' => $this->country,
                'error' => $e->getMessage(),
            ]);
            
            return $this->fallbackValidation($value);
        }
    }
    
    protected function fallbackValidation(string $value): bool
    {
        $patterns = [
            'US' => '/^\d{5}(-\d{4})?$/',
            'CA' => '/^[A-Za-z]\d[A-Za-z] \d[A-Za-z]\d$/',
            'UK' => '/^[A-Za-z]{1,2}\d[A-Za-z\d]?\s?\d[A-Za-z]{2}$/',
        ];
        
        if (isset($patterns[$this->country])) {
            return preg_match($patterns[$this->country], $value);
        }
        
        // Default: allow any alphanumeric with spaces and dashes
        return preg_match('/^[A-Za-z0-9\s\-]{3,10}$/', $value);
    }
    
    public function message(): string
    {
        return $this->message;
    }
}

Conditional Custom Rules

Create rules that change behavior based on other field values:

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\DataAwareRule;

class ConditionalFileSize implements Rule, DataAwareRule
{
    protected array $data = [];
    protected string $message;
    
    public function setData($data): static
    {
        $this->data = $data;
        return $this;
    }
    
    public function passes($attribute, $value): bool
    {
        if (!$value instanceof \Illuminate\Http\UploadedFile) {
            return true;
        }
        
        $fileType = $this->data['file_type'] ?? 'document';
        $userType = $this->data['user_type'] ?? 'basic';
        
        $limits = $this->getSizeLimits($fileType, $userType);
        $fileSizeKb = $value->getSize() / 1024;
        
        if ($fileSizeKb > $limits['max_size']) {
            $this->message = "The {$attribute} may not be greater than {$limits['max_size']}KB for {$userType} users uploading {$fileType} files.";
            return false;
        }
        
        return true;
    }
    
    protected function getSizeLimits(string $fileType, string $userType): array
    {
        $limits = [
            'basic' => [
                'image' => ['max_size' => 2048], // 2MB
                'document' => ['max_size' => 5120], // 5MB
                'video' => ['max_size' => 10240], // 10MB
            ],
            'premium' => [
                'image' => ['max_size' => 10240], // 10MB
                'document' => ['max_size' => 51200], // 50MB
                'video' => ['max_size' => 512000], // 500MB
            ],
        ];
        
        return $limits[$userType][$fileType] ?? $limits['basic']['document'];
    }
    
    public function message(): string
    {
        return $this->message;
    }
}

Complex Business Logic Validation

Multi-Field Validation Rules

Create rules that validate relationships between multiple fields:

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\DataAwareRule;
use Carbon\Carbon;

class ValidDateRange implements Rule, DataAwareRule
{
    protected array $data = [];
    protected string $endDateField;
    protected int $maxDurationDays;
    
    public function __construct(string $endDateField = 'end_date', int $maxDurationDays = 365)
    {
        $this->endDateField = $endDateField;
        $this->maxDurationDays = $maxDurationDays;
    }
    
    public function setData($data): static
    {
        $this->data = $data;
        return $this;
    }
    
    public function passes($attribute, $value): bool
    {
        $endDate = $this->data[$this->endDateField] ?? null;
        
        if (!$endDate) {
            return true; // Let other rules handle missing end date
        }
        
        try {
            $startDate = Carbon::parse($value);
            $endDate = Carbon::parse($endDate);
            
            // End date must be after start date
            if ($endDate->lte($startDate)) {
                return false;
            }
            
            // Duration cannot exceed maximum
            if ($startDate->diffInDays($endDate) > $this->maxDurationDays) {
                return false;
            }
            
            // Business rule: cannot book dates too far in advance
            if ($startDate->gt(Carbon::now()->addMonths(12))) {
                return false;
            }
            
            return true;
            
        } catch (\Exception $e) {
            return false;
        }
    }
    
    public function message(): string
    {
        return 'The date range is invalid. Ensure the end date is after the start date, duration does not exceed ' . $this->maxDurationDays . ' days, and dates are within the booking window.';
    }
}

Aggregate Validation Rules

Validate against aggregate data or business constraints:

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use App\Models\Order;
use App\Models\User;

class ValidOrderLimit implements Rule
{
    protected User $user;
    protected string $period;
    protected string $message;
    
    public function __construct(User $user, string $period = 'monthly')
    {
        $this->user = $user;
        $this->period = $period;
    }
    
    public function passes($attribute, $value): bool
    {
        $orderCount = $this->getOrderCount();
        $limit = $this->getOrderLimit();
        
        if ($orderCount >= $limit) {
            $this->message = "You have reached your {$this->period} order limit of {$limit} orders.";
            return false;
        }
        
        // Check total value limit
        $totalValue = $this->getTotalOrderValue();
        $valueLimit = $this->getValueLimit();
        
        if (($totalValue + $value) > $valueLimit) {
            $this->message = "This order would exceed your {$this->period} spending limit of \${$valueLimit}.";
            return false;
        }
        
        return true;
    }
    
    protected function getOrderCount(): int
    {
        return Order::where('user_id', $this->user->id)
                   ->where('created_at', '>=', $this->getPeriodStart())
                   ->count();
    }
    
    protected function getTotalOrderValue(): float
    {
        return Order::where('user_id', $this->user->id)
                   ->where('created_at', '>=', $this->getPeriodStart())
                   ->sum('total_amount');
    }
    
    protected function getOrderLimit(): int
    {
        return match($this->user->subscription_tier) {
            'basic' => 5,
            'premium' => 20,
            'enterprise' => 100,
            default => 3,
        };
    }
    
    protected function getValueLimit(): float
    {
        return match($this->user->subscription_tier) {
            'basic' => 1000.00,
            'premium' => 5000.00,
            'enterprise' => 25000.00,
            default => 500.00,
        };
    }
    
    protected function getPeriodStart(): \Carbon\Carbon
    {
        return match($this->period) {
            'daily' => now()->startOfDay(),
            'weekly' => now()->startOfWeek(),
            'monthly' => now()->startOfMonth(),
            'yearly' => now()->startOfYear(),
            default => now()->startOfMonth(),
        };
    }
    
    public function message(): string
    {
        return $this->message;
    }
}

Testing Custom Validation Rules

Unit Testing Rules

Create comprehensive tests for your custom rules:

<?php

namespace Tests\Unit\Rules;

use Tests\TestCase;
use App\Rules\ValidDomain;
use App\Services\DomainValidationService;
use Mockery;

class ValidDomainTest extends TestCase
{
    protected DomainValidationService $domainService;
    
    protected function setUp(): void
    {
        parent::setUp();
        $this->domainService = Mockery::mock(DomainValidationService::class);
    }
    
    public function test_passes_with_valid_domain(): void
    {
        $this->domainService->shouldReceive('isBlacklisted')
                           ->with('example.com')
                           ->andReturn(false);
                           
        $this->domainService->shouldReceive('resolves')
                           ->with('example.com')
                           ->andReturn(true);
        
        $rule = new ValidDomain($this->domainService);
        
        $this->assertTrue($rule->passes('domain', 'example.com'));
    }
    
    public function test_fails_with_blacklisted_domain(): void
    {
        $this->domainService->shouldReceive('isBlacklisted')
                           ->with('spam-domain.com')
                           ->andReturn(true);
        
        $rule = new ValidDomain($this->domainService);
        
        $this->assertFalse($rule->passes('domain', 'spam-domain.com'));
        $this->assertStringContains('not allowed', $rule->message());
    }
    
    public function test_fails_with_non_resolving_domain(): void
    {
        $this->domainService->shouldReceive('isBlacklisted')
                           ->with('non-existent-domain.com')
                           ->andReturn(false);
                           
        $this->domainService->shouldReceive('resolves')
                           ->with('non-existent-domain.com')
                           ->andReturn(false);
        
        $rule = new ValidDomain($this->domainService);
        
        $this->assertFalse($rule->passes('domain', 'non-existent-domain.com'));
        $this->assertStringContains('does not exist', $rule->message());
    }
    
    public function test_passes_with_empty_value(): void
    {
        $rule = new ValidDomain($this->domainService);
        
        $this->assertTrue($rule->passes('domain', ''));
        $this->assertTrue($rule->passes('domain', null));
    }
}

Integration Testing

Test rules within the context of form requests and controllers:

<?php

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;

class CustomValidationTest extends TestCase
{
    public function test_order_validation_with_limits(): void
    {
        $user = User::factory()->create(['subscription_tier' => 'basic']);
        
        // Create orders up to the limit
        Order::factory()->count(5)->create([
            'user_id' => $user->id,
            'created_at' => now(),
        ]);
        
        $this->actingAs($user)
             ->postJson('/orders', [
                 'total_amount' => 100.00,
                 'items' => [['product_id' => 1, 'quantity' => 1]],
             ])
             ->assertStatus(422)
             ->assertJsonValidationErrors(['total_amount']);
    }
    
    public function test_date_range_validation(): void
    {
        $this->postJson('/bookings', [
            'start_date' => '2024-12-01',
            'end_date' => '2024-11-30', // End before start
        ])
        ->assertStatus(422)
        ->assertJsonValidationErrors(['start_date']);
    }
}

Performance Considerations

Caching Validation Results

Cache expensive validation operations:

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Cache;

class CachedValidationRule implements Rule
{
    protected string $cacheKey;
    protected int $cacheTtl;
    
    public function __construct(string $identifier, int $cacheTtl = 300)
    {
        $this->cacheKey = "validation_" . md5($identifier);
        $this->cacheTtl = $cacheTtl;
    }
    
    public function passes($attribute, $value): bool
    {
        return Cache::remember($this->cacheKey, $this->cacheTtl, function () use ($value) {
            return $this->performValidation($value);
        });
    }
    
    protected function performValidation($value): bool
    {
        // Expensive validation logic here
        return true;
    }
    
    public function message(): string
    {
        return 'The :attribute is invalid.';
    }
}

Batch Validation

Optimize database queries for multiple validations:

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use App\Models\Product;

class ProductsExist implements Rule
{
    protected array $notFoundIds = [];
    
    public function passes($attribute, $value): bool
    {
        if (!is_array($value)) {
            return false;
        }
        
        $productIds = array_column($value, 'product_id');
        $existingIds = Product::whereIn('id', $productIds)
                             ->pluck('id')
                             ->toArray();
        
        $this->notFoundIds = array_diff($productIds, $existingIds);
        
        return empty($this->notFoundIds);
    }
    
    public function message(): string
    {
        $ids = implode(', ', $this->notFoundIds);
        return "The following product IDs do not exist: {$ids}";
    }
}

Integration with Form Requests

Combine custom rules with Laravel's form request validation for clean, maintainable validation logic:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use App\Rules\ValidDomain;
use App\Rules\ValidOrderLimit;

class CreateProjectRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Project::class);
    }
    
    public function rules(): array
    {
        return [
            'name' => 'required|string|max:255',
            'domain' => [
                'required',
                'string',
                new ValidDomain(app(DomainValidationService::class)),
            ],
            'budget' => [
                'required',
                'numeric',
                'min:0',
                new ValidOrderLimit($this->user(), 'monthly'),
            ],
            'start_date' => [
                'required',
                'date',
                'after:today',
                new ValidDateRange('end_date', 365),
            ],
            'end_date' => 'required|date|after:start_date',
        ];
    }
    
    public function messages(): array
    {
        return [
            'domain.required' => 'Please provide a domain for your project.',
            'budget.min' => 'Project budget must be greater than zero.',
        ];
    }
}

Custom validation rules are essential for building robust Laravel applications that enforce complex business logic while maintaining clean, readable code. When combined with proper testing and performance optimization, they provide a powerful foundation for enterprise Laravel applications.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Laravel