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
- Creating Basic Custom Rules
- Advanced Custom Rule Patterns
- Complex Business Logic Validation
- Testing Custom Validation Rules
- Performance Considerations
- Integration with Form Requests
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.
Add Comment
No comments yet. Be the first to comment!