Input Validation
Canvas provides a validation system that separates validation logic from controllers using the @InterceptWith annotation with ValidateAspect. The aspect intercepts requests before they reach your controller method, validates the input data, and either passes control to your controller or automatically handles the validation failure.
How ValidateAspect Works
When a request hits a route with @InterceptWith(ValidateAspect::class):
- The aspect instantiates your validation class
- Retrieves validation rules by calling
getRules() - Runs each rule against the corresponding request data
- If validation passes: sets
$request->attributes->set('validation_passed', true)and continues to your controller - If validation fails with
auto_respond=false: sets$request->attributes->set('validation_passed', false), sets$request->attributes->set('validation_errors', ['field1' => ['error1', 'error2'], 'field2' => ['error3']]), continues to controller - If validation fails with
auto_respond=true: returns JSON error response immediately, controller never executes
Creating Validation Classes
Define validation rules in dedicated classes that implement ValidationInterface:
<?php
namespace App\Validation;
use Quellabs\Canvas\Validation\Contracts\ValidationInterface;
use Quellabs\Canvas\Validation\Rules\NotBlank;
use Quellabs\Canvas\Validation\Rules\Email;
use Quellabs\Canvas\Validation\Rules\Length;
use Quellabs\Canvas\Validation\Rules\ValueIn;
class UserValidation implements ValidationInterface {
public function getRules(): array {
return [
'name' => [
new NotBlank('Name is required'),
new Length(2, null, 'Name must be at least {{min}} characters')
],
'email' => [
new NotBlank('Email is required'),
new Email('Please enter a valid email address')
],
'password' => [
new NotBlank('Password is required'),
new Length(8, null, 'Password must be at least {{min}} characters')
],
'role' => [
new ValueIn(['admin', 'user', 'moderator'], 'Please select a valid role')
]
];
}
}
Applying Validation to Controllers
HTML Form Validation (Manual Error Handling)
For traditional server-rendered forms, validation failures pass control to your controller so you can re-render the form with errors:
<?php
namespace App\Controllers;
use Quellabs\Canvas\Annotations\Route;
use Quellabs\Canvas\Annotations\InterceptWith;
use Quellabs\Canvas\Validation\ValidateAspect;
use App\Validation\UserValidation;
use Symfony\Component\HttpFoundation\Request;
class UserController extends BaseController {
/**
* @Route("/users/create", methods={"GET", "POST"})
* @InterceptWith(ValidateAspect::class, validator=UserValidation::class)
*/
public function create(Request $request) {
if ($request->isMethod('POST')) {
// ValidateAspect has already run validation
if ($request->attributes->get('validation_passed', false)) {
// Validation passed - process the form
$user = new User();
$user->setName($request->request->get('name'));
$user->setEmail($request->request->get('email'));
$this->em()->persist($user);
$this->em()->flush();
return $this->redirect('/users');
}
// Validation failed - re-render form with errors
return $this->render('users/create.tpl', [
'errors' => $request->attributes->get('validation_errors', []),
'old' => $request->request->all()
]);
}
// GET request - show empty form
return $this->render('users/create.tpl');
}
}
API Validation (Automatic Error Responses)
For API endpoints, set auto_respond=true to automatically return JSON error responses when validation fails. Your controller method only executes when validation passes:
/**
* @Route("/api/users", methods={"POST"})
* @InterceptWith(ValidateAspect::class, validator=UserValidation::class, auto_respond=true)
*/
public function createUser(Request $request) {
// If this code executes, validation has already passed
// Validation failures return JSON automatically:
// {
// "message": "Validation failed",
// "errors": {
// "email": ["Please enter a valid email address"],
// "password": ["Password must be at least 8 characters"]
// }
// }
$user = $this->createUserFromRequest($request);
return $this->json(['success' => true, 'user_id' => $user->getId()]);
}
Standalone Validation (Without AOP)
The Validator class can be used independently of the ValidateAspect to validate any array of data. This is useful when:
- Validating data in service layers or business logic
- Processing batch imports (CSV, JSON files)
- Validating data from non-HTTP sources (message queues, CLI commands)
- Testing validation rules directly
- Building custom validation flows
Basic Usage
<?php
use Quellabs\Canvas\Validation\Validator;
use App\Validation\UserValidation;
// Create validator instance
$validator = new Validator();
// Data to validate (from any source)
$data = [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'secret123'
];
// Create validation rules instance
$rules = new UserValidation();
// Validate the data
$errors = $validator->validate($data, $rules);
if (empty($errors)) {
// Validation passed - process the data
$this->createUser($data);
} else {
// Validation failed - handle errors
// $errors = ['email' => ['Please enter a valid email address']]
}
Built-in Validation Rules
Basic Rules
// In your validation class getRules() method:
// Required fields
'name' => [new NotBlank('Name is required')],
// Length constraints
'description' => [new Length(10, 500, 'Must be between {{min}} and {{max}} characters')],
// Type checking
'age' => [new Type('integer', 'Must be a number')],
// Allowed values
'category' => [new ValueIn(['option1', 'option2'], 'Must be a valid option')],
// At least one field required
'contact' => [new AtLeastOneOf(['email', 'phone'], 'Provide email or phone')]
Format Validation
// Email validation
'email' => [new Email('Please enter a valid email address')],
// Phone number validation
'phone' => [new PhoneNumber('Please enter a valid phone number')],
// Date format validation
'birthdate' => [new Date('Y-m-d', 'Date must be in YYYY-MM-DD format')],
// Regular expression matching
'username' => [new RegExp('/^[a-zA-Z0-9_]+$/', 'Only letters, numbers, and underscores')]
Security Rules
// Prevent HTML injection
'comment' => [
new NotHTML('HTML tags are not allowed'),
new NotLongWord(50, 'Words cannot exceed {{max}} characters')
]
File Upload Validation
// File validation rules
'profile_image' => [
new FileRequired('Profile image is required'),
new FileSize(0, 5 * 1024 * 1024, 'Image must be smaller than 5MB'),
new FileType(['image/jpeg', 'image/png'], 'Image must be JPEG or PNG')
]
Custom Validation Rules
Create custom validation rules by implementing ValidationRuleInterface:
<?php
namespace App\Validation\Rules;
use Quellabs\Canvas\Validation\Contracts\ValidationRuleInterface;
class StrongPassword implements ValidationRuleInterface {
public function validate($value, array $options = []): bool {
if (empty($value)) {
return false;
}
// Require uppercase, lowercase, number, and special character
return preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/', $value);
}
public function getMessage(): string {
return 'Password must contain uppercase, lowercase, number, and special character';
}
}
class UniqueEmail implements ValidationRuleInterface {
public function __construct(private EntityManager $em) {}
public function validate($value, array $options = []): bool {
// Empty values should be caught by NotBlank if required
if (empty($value)) {
return true;
}
$existing = $this->em()->findBy(User::class, ['email' => $value]);
return count($existing) === 0;
}
public function getMessage(): string {
return 'This email address is already in use';
}
}
Conditional Validation
Apply different validation rules based on context by using constructor parameters:
<?php
namespace App\Validation;
use Quellabs\Canvas\Validation\Contracts\ValidationInterface;
class ConditionalUserValidation implements ValidationInterface {
public function __construct(private string $context = 'create') {}
public function getRules(): array {
$rules = [
'name' => [new NotBlank('Name is required')],
'email' => [new NotBlank('Email is required'), new Email()]
];
// Require password for creation, optional for updates
if ($this->context === 'create') {
$rules['password'] = [
new NotBlank('Password is required'),
new Length(8, null, 'Password must be at least {{min}} characters')
];
}
return $rules;
}
}
// Usage in controller - pass context via annotation parameters
/**
* @Route("/users", methods={"POST"})
* @InterceptWith(ValidateAspect::class, validator=ConditionalUserValidation::class, context="create")
*/
public function create(Request $request) {
// Password validation is required
}
/**
* @Route("/users/{id}", methods={"PUT"})
* @InterceptWith(ValidateAspect::class, validator=ConditionalUserValidation::class, context="update")
*/
public function update(Request $request, int $id) {
// Password validation is optional
}
Best Practices
- One validator per use case: Create separate validation classes for different operations (UserCreateValidation vs. UserUpdateValidation)
- User-friendly messages: Write error messages users can understand and act on, avoid technical jargon
- Efficient rule ordering: Place cheap checks (NotBlank, Length) before expensive operations (database queries, API calls)
- Separate validation from sanitization: Use SanitizeAspect to clean data, ValidateAspect to verify it
- Reusable custom rules: Extract common validation logic into custom rule classes that can be used across validators
- Empty value handling: Let NotBlank handle empty values, skip validation in other rules when value is empty