User Account Validation
Difficulty: Basic | Time: 30 minutes | Integration: Form Requests, Policies, Custom Rules
Refactor scattered user validation logic into clean, reusable specifications that work seamlessly with Laravel's validation system and authorization policies.
The Problem: Scattered Validation Logic
Your user management system has validation rules spread across controllers, form requests, and service classes, making it hard to maintain consistent business rules.
🔍 Examining the Existing Code
Here's the problematic validation scattered across your application:
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function store(Request $request)
{
// Validation logic mixed with business logic
$validated = $request->validate([
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
'name' => 'required|string|max:255',
'phone' => 'nullable|string',
]);
// Additional business rules buried in controller
if (!empty($validated['phone'])) {
if (!preg_match('/^\+?[1-9]\d{1,14}$/', $validated['phone'])) {
return back()->withErrors(['phone' => 'Invalid phone format']);
}
}
// Age validation buried in controller
if ($request->has('birth_date')) {
$birthDate = Carbon::parse($request->birth_date);
$age = $birthDate->diffInYears(now());
if ($age < 18) {
return back()->withErrors(['birth_date' => 'Must be 18 or older']);
}
}
// Username validation scattered
if ($request->has('username')) {
$username = $request->username;
if (strlen($username) < 3) {
return back()->withErrors(['username' => 'Username too short']);
}
if (preg_match('/[^a-zA-Z0-9_]/', $username)) {
return back()->withErrors(['username' => 'Invalid username characters']);
}
if (User::where('username', $username)->exists()) {
return back()->withErrors(['username' => 'Username taken']);
}
}
User::create($validated);
return redirect()->route('users.index');
}
public function update(Request $request, User $user)
{
// Duplicate validation logic! 😱
$validated = $request->validate([
'email' => 'required|email|unique:users,email,' . $user->id,
'name' => 'required|string|max:255',
'phone' => 'nullable|string',
]);
// More duplicate business rules
if (!empty($validated['phone'])) {
if (!preg_match('/^\+?[1-9]\d{1,14}$/', $validated['phone'])) {
return back()->withErrors(['phone' => 'Invalid phone format']);
}
}
// Can user update their own profile? Business logic in controller!
if ($user->is_suspended && !auth()->user()->hasRole('admin')) {
return back()->withErrors(['general' => 'Suspended users cannot update profile']);
}
$user->update($validated);
return redirect()->route('users.show', $user);
}
}
🎯 Business Logic Analysis
Let's identify the distinct business rules:
- Age Requirement: Users must be 18 or older
- Phone Format: Valid international phone number format
- Username Rules: 3+ characters, alphanumeric + underscore only, must be unique
- Profile Update Eligibility: Non-suspended users can update their own profile
- Admin Override: Administrators can modify any user account
Step 1: Generate Specifications
Using the Artisan command to create our specifications:
# Core validation specifications
php artisan make:specification User/AgeEligibilitySpecification --model=User
php artisan make:specification User/ValidPhoneSpecification
php artisan make:specification User/ValidUsernameSpecification --model=User
php artisan make:specification User/ProfileUpdateEligibilitySpecification --model=User
# Composite specification for complete validation
php artisan make:specification User/CompleteUserValidationSpecification --composite
Step 2: Implement Core Specifications
AgeEligibilitySpecification
<?php
namespace App\Specifications\User;
use Carbon\Carbon;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class AgeEligibilitySpecification extends AbstractSpecification
{
public function __construct(
private readonly int $minimumAge = 18
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (is_array($candidate) && isset($candidate['birth_date'])) {
$birthDate = Carbon::parse($candidate['birth_date']);
return $birthDate->diffInYears(now()) >= $this->minimumAge;
}
if (is_object($candidate) && isset($candidate->birth_date)) {
$birthDate = Carbon::parse($candidate->birth_date);
return $birthDate->diffInYears(now()) >= $this->minimumAge;
}
return false;
}
public function toQuery(Builder $query): Builder
{
$cutoffDate = now()->subYears($this->minimumAge);
return $query->where('birth_date', '<=', $cutoffDate);
}
public function getErrorMessage(): string
{
return "Must be {$this->minimumAge} years or older.";
}
}
ValidPhoneSpecification
<?php
namespace App\Specifications\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class ValidPhoneSpecification extends AbstractSpecification
{
private string $phonePattern = '/^\+?[1-9]\d{1,14}$/';
public function isSatisfiedBy(mixed $candidate): bool
{
// Handle different input types
$phone = null;
if (is_string($candidate)) {
$phone = $candidate;
} elseif (is_array($candidate) && isset($candidate['phone'])) {
$phone = $candidate['phone'];
} elseif (is_object($candidate) && isset($candidate->phone)) {
$phone = $candidate->phone;
}
// Empty phone is valid (nullable field)
if (empty($phone)) {
return true;
}
// Remove all whitespace for validation
$cleanPhone = preg_replace('/\s+/', '', $phone);
return preg_match($this->phonePattern, $cleanPhone) === 1;
}
public function toQuery(Builder $query): Builder
{
// In a real application, you might use database REGEXP
// For now, we'll return all and filter in PHP
return $query->whereNotNull('phone');
}
public function getErrorMessage(): string
{
return 'Phone number must be a valid international format.';
}
public function formatPhone(string $phone): string
{
return preg_replace('/\s+/', '', $phone);
}
}
ValidUsernameSpecification
<?php
namespace App\Specifications\User;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class ValidUsernameSpecification extends AbstractSpecification
{
private int $minLength = 3;
private int $maxLength = 30;
private string $allowedPattern = '/^[a-zA-Z0-9_]+$/';
public function __construct(
private readonly ?int $excludeUserId = null
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
$username = $this->extractUsername($candidate);
if (empty($username)) {
return false;
}
return $this->hasValidLength($username)
&& $this->hasValidCharacters($username)
&& $this->isUnique($username);
}
public function toQuery(Builder $query): Builder
{
return $query->where('username', 'REGEXP', '^[a-zA-Z0-9_]{3,30}$');
}
private function extractUsername(mixed $candidate): ?string
{
if (is_string($candidate)) {
return $candidate;
}
if (is_array($candidate) && isset($candidate['username'])) {
return $candidate['username'];
}
if (is_object($candidate) && isset($candidate->username)) {
return $candidate->username;
}
return null;
}
private function hasValidLength(string $username): bool
{
$length = strlen($username);
return $length >= $this->minLength && $length <= $this->maxLength;
}
private function hasValidCharacters(string $username): bool
{
return preg_match($this->allowedPattern, $username) === 1;
}
private function isUnique(string $username): bool
{
$query = User::where('username', $username);
if ($this->excludeUserId) {
$query->where('id', '!=', $this->excludeUserId);
}
return !$query->exists();
}
public function getValidationErrors(mixed $candidate): array
{
$errors = [];
$username = $this->extractUsername($candidate);
if (empty($username)) {
$errors[] = 'Username is required.';
return $errors;
}
if (!$this->hasValidLength($username)) {
$errors[] = "Username must be between {$this->minLength} and {$this->maxLength} characters.";
}
if (!$this->hasValidCharacters($username)) {
$errors[] = 'Username can only contain letters, numbers, and underscores.';
}
if (!$this->isUnique($username)) {
$errors[] = 'Username is already taken.';
}
return $errors;
}
}
ProfileUpdateEligibilitySpecification
<?php
namespace App\Specifications\User;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class ProfileUpdateEligibilitySpecification extends AbstractSpecification
{
public function __construct(
private readonly ?User $currentUser = null
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
$currentUser = $this->currentUser ?? auth()->user();
if (!$currentUser) {
return false;
}
// Admins can update any profile
if ($currentUser->hasRole('admin')) {
return true;
}
// Users can only update their own profile if not suspended
if ($currentUser->id === $candidate->id) {
return !$candidate->is_suspended;
}
return false;
}
public function toQuery(Builder $query): Builder
{
$currentUser = $this->currentUser ?? auth()->user();
if (!$currentUser) {
return $query->whereRaw('1 = 0'); // No matches
}
if ($currentUser->hasRole('admin')) {
return $query; // Admin can see all
}
// Users can only see their own profile if not suspended
return $query->where('id', $currentUser->id)
->where('is_suspended', false);
}
public function canUpdateField(User $user, string $field): bool
{
if (!$this->isSatisfiedBy($user)) {
return false;
}
// Some fields might be admin-only
$adminOnlyFields = ['is_suspended', 'role', 'email_verified_at'];
if (in_array($field, $adminOnlyFields)) {
return $this->currentUser?->hasRole('admin') ?? false;
}
return true;
}
}
Step 3: Create Custom Validation Rules
Now let's create Laravel validation rules using our specifications:
<?php
namespace App\Rules;
use App\Specifications\User\AgeEligibilitySpecification;
use Illuminate\Contracts\Validation\Rule;
class MinimumAgeRule implements Rule
{
private AgeEligibilitySpecification $spec;
private int $minimumAge;
public function __construct(int $minimumAge = 18)
{
$this->minimumAge = $minimumAge;
$this->spec = new AgeEligibilitySpecification($minimumAge);
}
public function passes($attribute, $value)
{
return $this->spec->isSatisfiedBy([$attribute => $value]);
}
public function message()
{
return $this->spec->getErrorMessage();
}
}
<?php
namespace App\Rules;
use App\Specifications\User\ValidUsernameSpecification;
use Illuminate\Contracts\Validation\Rule;
class UsernameRule implements Rule
{
private ValidUsernameSpecification $spec;
public function __construct(?int $excludeUserId = null)
{
$this->spec = new ValidUsernameSpecification($excludeUserId);
}
public function passes($attribute, $value)
{
return $this->spec->isSatisfiedBy($value);
}
public function message()
{
return 'The username format is invalid or already taken.';
}
public function getDetailedErrors($value): array
{
return $this->spec->getValidationErrors($value);
}
}
<?php
namespace App\Rules;
use App\Specifications\User\ValidPhoneSpecification;
use Illuminate\Contracts\Validation\Rule;
class PhoneRule implements Rule
{
private ValidPhoneSpecification $spec;
public function __construct()
{
$this->spec = new ValidPhoneSpecification();
}
public function passes($attribute, $value)
{
return $this->spec->isSatisfiedBy($value);
}
public function message()
{
return $this->spec->getErrorMessage();
}
}
Step 4: Create Form Requests
Let's refactor our controllers to use proper Form Requests with specification-based validation:
<?php
namespace App\Http\Requests;
use App\Rules\MinimumAgeRule;
use App\Rules\PhoneRule;
use App\Rules\UsernameRule;
use Illuminate\Foundation\Http\FormRequest;
class CreateUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'birth_date' => ['required', 'date', new MinimumAgeRule(18)],
'phone' => ['nullable', new PhoneRule()],
'username' => ['required', new UsernameRule()],
];
}
public function messages(): array
{
return [
'birth_date.required' => 'Please provide your birth date.',
'username.required' => 'Please choose a username.',
];
}
}
<?php
namespace App\Http\Requests;
use App\Rules\PhoneRule;
use App\Rules\UsernameRule;
use App\Specifications\User\ProfileUpdateEligibilitySpecification;
use Illuminate\Foundation\Http\FormRequest;
class UpdateUserRequest extends FormRequest
{
public function authorize(): bool
{
$spec = new ProfileUpdateEligibilitySpecification();
return $spec->isSatisfiedBy($this->route('user'));
}
public function rules(): array
{
$userId = $this->route('user')->id;
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', "unique:users,email,{$userId}"],
'phone' => ['nullable', new PhoneRule()],
'username' => ['sometimes', new UsernameRule($userId)],
];
}
}
Step 5: Refactored Controller
Now our controller is much cleaner:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\CreateUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Models\User;
class UserController extends Controller
{
public function store(CreateUserRequest $request)
{
// All validation logic is now handled by the Form Request
// and our specifications!
User::create($request->validated());
return redirect()
->route('users.index')
->with('success', 'User created successfully!');
}
public function update(UpdateUserRequest $request, User $user)
{
// Clean, simple controller method
$user->update($request->validated());
return redirect()
->route('users.show', $user)
->with('success', 'User updated successfully!');
}
}
Step 6: Policy Integration
Create a policy that uses our specifications:
<?php
namespace App\Policies;
use App\Models\User;
use App\Specifications\User\ProfileUpdateEligibilitySpecification;
class UserPolicy
{
public function __construct(
private readonly ProfileUpdateEligibilitySpecification $updateSpec
) {}
public function update(User $currentUser, User $targetUser): bool
{
$spec = new ProfileUpdateEligibilitySpecification($currentUser);
return $spec->isSatisfiedBy($targetUser);
}
public function updateField(User $currentUser, User $targetUser, string $field): bool
{
$spec = new ProfileUpdateEligibilitySpecification($currentUser);
return $spec->canUpdateField($targetUser, $field);
}
public function view(User $currentUser, User $targetUser): bool
{
// Users can view their own profile, admins can view any
return $currentUser->id === $targetUser->id
|| $currentUser->hasRole('admin');
}
}
Step 7: Testing Setup
Let's write comprehensive tests for our specifications:
PHPUnit Tests
<?php
namespace Tests\Unit\Specifications;
use App\Specifications\User\AgeEligibilitySpecification;
use App\Specifications\User\ValidPhoneSpecification;
use App\Specifications\User\ValidUsernameSpecification;
use Tests\TestCase;
use Carbon\Carbon;
class UserValidationSpecificationTest extends TestCase
{
/** @test */
public function age_eligibility_validates_minimum_age()
{
$spec = new AgeEligibilitySpecification(18);
$validUser = ['birth_date' => now()->subYears(20)->toDateString()];
$invalidUser = ['birth_date' => now()->subYears(16)->toDateString()];
$this->assertTrue($spec->isSatisfiedBy($validUser));
$this->assertFalse($spec->isSatisfiedBy($invalidUser));
}
/** @test */
public function phone_specification_validates_international_format()
{
$spec = new ValidPhoneSpecification();
$this->assertTrue($spec->isSatisfiedBy('+1234567890'));
$this->assertTrue($spec->isSatisfiedBy('1234567890'));
$this->assertTrue($spec->isSatisfiedBy('')); // Empty is valid (nullable)
$this->assertFalse($spec->isSatisfiedBy('123'));
$this->assertFalse($spec->isSatisfiedBy('+0123456789'));
$this->assertFalse($spec->isSatisfiedBy('abc123'));
}
/** @test */
public function username_specification_validates_format_and_uniqueness()
{
$spec = new ValidUsernameSpecification();
$this->assertTrue($spec->isSatisfiedBy('valid_username'));
$this->assertTrue($spec->isSatisfiedBy('user123'));
$this->assertFalse($spec->isSatisfiedBy('ab')); // Too short
$this->assertFalse($spec->isSatisfiedBy('user@name')); // Invalid characters
$this->assertFalse($spec->isSatisfiedBy('user name')); // Spaces not allowed
}
/** @test */
public function username_specification_provides_detailed_validation_errors()
{
$spec = new ValidUsernameSpecification();
$errors = $spec->getValidationErrors('a');
$this->assertContains('Username must be between 3 and 30 characters.', $errors);
}
}
Pest Tests
<?php
use App\Models\User;
use App\Specifications\User\ProfileUpdateEligibilitySpecification;
use App\Specifications\User\AgeEligibilitySpecification;
beforeEach(function () {
$this->admin = User::factory()->create(['role' => 'admin']);
$this->user = User::factory()->create(['is_suspended' => false]);
$this->suspendedUser = User::factory()->create(['is_suspended' => true]);
});
it('allows admins to update any user profile', function () {
$spec = new ProfileUpdateEligibilitySpecification($this->admin);
expect($spec->isSatisfiedBy($this->user))->toBeTrue();
expect($spec->isSatisfiedBy($this->suspendedUser))->toBeTrue();
});
it('allows users to update their own profile when not suspended', function () {
$spec = new ProfileUpdateEligibilitySpecification($this->user);
expect($spec->isSatisfiedBy($this->user))->toBeTrue();
});
it('prevents suspended users from updating their profile', function () {
$spec = new ProfileUpdateEligibilitySpecification($this->suspendedUser);
expect($spec->isSatisfiedBy($this->suspendedUser))->toBeFalse();
});
it('validates age eligibility correctly', function () {
$spec = new AgeEligibilitySpecification(18);
$adult = ['birth_date' => now()->subYears(25)->toDateString()];
$minor = ['birth_date' => now()->subYears(15)->toDateString()];
expect($spec->isSatisfiedBy($adult))->toBeTrue();
expect($spec->isSatisfiedBy($minor))->toBeFalse();
});
it('validates phone formats correctly', function () {
$spec = new ValidPhoneSpecification();
expect($spec->isSatisfiedBy('+12345678901'))->toBeTrue();
expect($spec->isSatisfiedBy('2345678901'))->toBeTrue();
expect($spec->isSatisfiedBy(''))->toBeTrue(); // nullable
expect($spec->isSatisfiedBy('123'))->toBeFalse();
expect($spec->isSatisfiedBy('+0123456789'))->toBeFalse();
});
Key Learnings
This refactoring demonstrates:
✅ Separation of Concerns: Validation logic separated from controllers
✅ Reusable Rules: Specifications work across forms, policies, and queries
✅ Custom Validation: Laravel validation rules powered by specifications
✅ Policy Integration: Authorization logic using specifications
✅ Comprehensive Testing: Both PHPUnit and Pest test strategies
✅ Clean Controllers: Business logic moved to appropriate layers
Next Steps
With your user validation system refactored:
- Extend validation rules by creating new specifications
- Add admin-specific validation using role-based specifications
- Integrate with API resources for consistent validation across web and API
- Add audit logging when validation specifications are triggered
Next: Learn collection processing with Email Campaign Targeting.