Custom Validation Rules
For validation logic beyond the built-in rules, create custom validation callbacks. Creating your own custom validation callbacks gives you unlimited flexibility while maintaining Trongate's security-first approach.
The Pattern
Custom validation uses the callback_ prefix in rules, with secured methods protected by block_url():
- Add
callback_method_name to your validation rules
- Create a method that receives the field value
- Call
block_url('module/method') at the start to prevent direct URL access
- Return
true (pass) or an error string (fail)
Security First: Validation callback methods should call block_url() to prevent direct URL access. This protects your validation logic from being invoked outside the validation flow.
Input is Pre-Cleaned: The value passed to your callback method has already been processed by the post() helper function, specifically with post($field, true). This means whitespace is trimmed and multiple spaces are collapsed.
Therefore, within your form validation callback methods, you don't need to clean the input again - just validate it.
Basic Example
public function submit(): void {
$this->validation->set_rules('username', 'username', 'required|callback_username_available');
if ($this->validation->run() === true) {
// Save data
} else {
$this->create(); // Display the 'create' form again.
}
}
public function username_available(string $username): string|bool {
block_url('members/username_available');
// No cleaning needed - value is already cleaned by post($field, true)
$existing = $this->db->get_one_where('username', $username, 'users');
if ($existing !== false) {
return 'The {label} is already taken.';
}
return true;
}
When the form is submitted with a username that already exists in the database, the error message displays: "The username is already taken." The {label} placeholder is automatically replaced with the field label you defined in set_rules().
How It Works
When validation runs:
- Validation system retrieves field value using
post($field, true) (cleaned and trimmed)
- Trongate sees
callback_method_name in the validation rules
- Calls the method with the cleaned field value
- The method calls
block_url('module/method') to secure the endpoint
- If method returns a string: Validation fails, string becomes error message (with
{label} substitution if present)
- If method returns
true: Validation passes
Return Values
| Return |
Result |
Example |
true |
Validation passes |
return true; |
| String (error message) |
Validation fails with custom message |
return 'Invalid format.'; |
String with {label} |
Fails with label substitution |
return 'The {label} must be unique.'; |
Important: Validation callback methods must only return either true or a string.
Custom validation callback methods must never return false, null, or other values.
Real-World Examples
For the examples below, we'll assume the code exists in a controller file named Members.php (in a members module).
Security Tip: Protect your custom validation callbacks from direct URL access by calling block_url() at the start of each method.
In those instances, pass the module name and method name as a single string, separated by a forward slash. For example: block_url('members/method_name').
Username Validation (Create and Update)
Allow usernames only during creation, or allow existing username to remain unchanged during updates:
public function submit(): void {
$this->validation->set_rules('username', 'username', 'required|min_length[2]|max_length[50]|callback_username_check');
if ($this->validation->run() === true) {
$update_id = segment(3, 'int');
$data = $this->model->get_post_data_for_database();
if ($update_id > 0) {
$this->db->update($update_id, $data, 'users');
$flash_msg = 'User updated successfully';
} else {
$update_id = $this->db->insert($data, 'users');
$flash_msg = 'User created successfully';
}
set_flashdata($flash_msg);
redirect('users/show/'.$update_id);
} else {
$this->create();
}
}
/**
* Validates username availability and format for create/update operations.
*
* @param string $username The username to check (pre-cleaned by validation system).
* @return string|bool Error message if validation fails, true if passes.
*/
public function username_check(string $username): string|bool {
block_url('members/username_check');
if ($username === '') {
return true;
}
if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
return 'The {label} can only contain letters, numbers, and underscores.';
}
$update_id = (int) segment(3);
$existing = $this->db->get_one_where('username', $username, 'users');
if ($existing === false) {
return true;
}
$existing_id = (int) $existing->id;
if ($update_id === 0 || $existing_id !== $update_id) {
return 'The {label} is already in use by another account.';
}
return true;
}
Key insights: This example handles both creation and updates. When creating ($update_id === 0), any existing username fails. When updating, the existing username is allowed only if it belongs to the same user being edited.
Email Uniqueness with Security
For sensitive fields like email, use generic error messages to prevent account enumeration attacks:
public function submit(): void {
$this->validation->set_rules('email', 'email address', 'required|valid_email|callback_email_unique');
if ($this->validation->run() === true) {
// Save user
$data = $this->model->get_post_data_for_database();
$this->db->insert($data, 'users');
} else {
$this->create();
}
}
/**
* Validates email is unique without revealing if an email exists in the system.
*
* @param string $email The email address to check.
* @return string|bool Generic error message if exists, true otherwise.
*/
public function email_unique(string $email): string|bool {
block_url('members/email_unique');
$existing = $this->db->get_one_where('email', $email, 'users');
if ($existing !== false) {
// Generic message - don't reveal that the email exists
return 'This email address cannot be used. Please try a different one.';
}
return true;
}
Security note: Notice how the error message doesn't say "email already exists" - it uses a generic message instead. This prevents attackers from using the validation system to enumerate valid email addresses.
Discount Code Validation
Check if a discount code exists and is valid before allowing form submission:
public function submit(): void {
$this->validation->set_rules('promo_code', 'promo code', 'callback_validate_discount_code');
if ($this->validation->run() === true) {
// Process order with discount
} else {
$this->create();
}
}
/**
* Validates a discount code exists and is currently active.
*
* @param string $code The discount code to validate.
* @return string|bool Error message if invalid, true if valid.
*/
public function validate_discount_code(string $code): string|bool {
block_url('members/validate_discount_code');
// Empty codes are allowed (discount is optional)
if ($code === '') {
return true;
}
$discount = $this->db->get_one_where('code', strtoupper($code), 'discounts');
if ($discount === false) {
return 'The {label} you entered is not valid.';
}
// Check if discount is still active
$today = date('Y-m-d');
if ($discount->expires < $today) {
return 'The {label} has expired.';
}
return true;
}
Product Code Validation
Verify a product code exists in inventory and is not discontinued:
public function submit(): void {
$this->validation->set_rules('product_code', 'product code', 'required|callback_check_product_code');
if ($this->validation->run() === true) {
// Save order with valid product
} else {
$this->create();
}
}
/**
* Validates a product code exists and is available for purchase.
*
* @param string $code The product code to check.
* @return string|bool Error message if unavailable, true if available.
*/
public function check_product_code(string $code): string|bool {
block_url('members/check_product_code');
$product = $this->db->get_one_where('code', strtoupper($code), 'products');
if ($product === false) {
return 'The {label} does not exist.';
}
if ((int) $product->active === 0) {
return 'The {label} is no longer available.';
}
return true;
}
Sale Price Validation
Ensure a sale price is less than the original price:
public function submit(): void {
$this->validation->set_rules('original_price', 'original price', 'required|numeric|greater_than[0]');
$this->validation->set_rules('sale_price', 'sale price', 'required|numeric|callback_sale_price_valid');
if ($this->validation->run() === true) {
// Save product with sale pricing
} else {
$this->create();
}
}
/**
* Validates sale price is less than original price.
*
* @param string $sale_price The sale price entered.
* @return string|bool Error message if invalid, true if valid.
*/
public function sale_price_valid(string $sale_price): string|bool {
block_url('members/sale_price_valid');
$original_price = (float) post('original_price', true);
$sale_price = (float) $sale_price;
if ($sale_price >= $original_price) {
return 'The {label} must be less than the original price.';
}
return true;
}
Checkbox Agreement Validation
Require users to agree to terms or confirm they meet age requirements:
public function submit(): void {
$this->validation->set_rules('agree_to_terms', 'terms agreement', 'required|callback_terms_agreed');
if ($this->validation->run() === true) {
// Process registration
} else {
$this->create();
}
}
/**
* Validates user has agreed to terms (checkbox must be checked).
*
* @param string $value The checkbox value (will be '1' if checked, empty if not).
* @return string|bool Error message if not agreed, true if agreed.
*/
public function terms_agreed(string $value): string|bool {
block_url('members/terms_agreed');
$value = (string) $value;
if ($value !== '1') {
return 'You must agree to the {label} to proceed.';
}
return true;
}
Note: Checkboxes return '1' when checked and an empty string when unchecked. We explicitly cast to string for type consistency.
External API Validation
Call an external service to validate data. For example, verify a phone number with a telecom API:
public function submit(): void {
$this->validation->set_rules('phone_number', 'phone number', 'required|callback_verify_phone_number');
if ($this->validation->run() === true) {
// Save contact with verified number
} else {
$this->create();
}
}
/**
* Validates phone number format and checks it with an external API.
*
* @param string $phone The phone number to verify.
* @return string|bool Error message if invalid, true if valid.
*/
public function verify_phone_number(string $phone): string|bool {
block_url('members/verify_phone_number');
// Basic format check first
if (!preg_match('/^\+?[\d\s\-\(\)]{10,}$/', $phone)) {
return 'The {label} must be a valid format.';
}
try {
// Call external API (example using a hypothetical phone validation service)
$api_url = 'https://api.phoneverification.service/validate';
$response = file_get_contents(
$api_url . '?phone=' . urlencode($phone),
false,
stream_context_create(['http' => ['timeout' => 5]])
);
$result = json_decode($response, true);
if (!$result['valid']) {
return 'The {label} could not be verified. Please check and try again.';
}
return true;
} catch (Exception $e) {
// If API is unavailable, allow submission (don't block user)
return true;
}
}
Conditional Logic Validation
Validate one field based on the value of another field. For example, require "other reason" only if "reason" is set to "other":
public function submit(): void {
$this->validation->set_rules('reason', 'reason', 'required');
$this->validation->set_rules('other_reason', 'other reason', 'callback_validate_conditional_reason');
if ($this->validation->run() === true) {
// Process form
} else {
$this->create();
}
}
/**
* Validates "other reason" field - only required if reason is "other".
*
* @param string $value The "other reason" value.
* @return string|bool Error message if invalid, true if valid.
*/
public function validate_conditional_reason(string $value): string|bool {
block_url('members/validate_conditional_reason');
$reason = post('reason', true);
if ($reason === 'other' && $value === '') {
return 'Please specify the {label}.';
}
return true;
}
Complex Business Logic Validation
Validate against multiple conditions that represent your business rules. For example, ensure a user has sufficient credits before allowing a purchase:
public function submit(): void {
$this->validation->set_rules('quantity', 'quantity', 'required|integer|greater_than[0]|callback_verify_sufficient_credits');
if ($this->validation->run() === true) {
// Proceed with purchase
} else {
$this->create();
}
}
/**
* Validates user has sufficient credits for the requested purchase quantity.
*
* @param string $quantity The quantity being purchased.
* @return string|bool Error message if insufficient credits, true if valid.
*/
public function verify_sufficient_credits(string $quantity): string|bool {
block_url('members/verify_sufficient_credits');
$user_id = auth_user('id');
$quantity = (int) $quantity;
// Get user's current credits
$user = $this->db->get_one_where('id', $user_id, 'users');
if ($user === false) {
return 'User information could not be retrieved.';
}
// Get cost per unit
$product_code = post('product_code', true);
$product = $this->db->get_one_where('code', $product_code, 'products');
if ($product === false) {
return 'The product could not be found.';
}
$total_cost = (float) $product->price * $quantity;
$available_credits = (float) $user->credits;
if ($available_credits < $total_cost) {
$shortfall = number_format($total_cost - $available_credits, 2);
return "Insufficient credits. You need {$shortfall} more to complete this purchase.";
}
return true;
}
- Always secure your callback methods: Call
block_url('module/method') at the start of each callback to prevent direct URL access.
- Use correct block_url() syntax: Pass the module name and method name separated by a forward slash:
block_url('members/username_check').
- Don't clean input: Input is already cleaned by
post($field, true) - no need to trim or process again.
- Handle empty optional fields: Return
true for optional fields when the value is an empty string.
- Use framework database helpers: Prefer
get_one_where() over manual query_bind() for simple queries.
- Explicit comparisons: Use
!== false for database result checks to distinguish between "no results" and "actual data."
- Use {label} appropriately: Use
{label} placeholders where they create natural, readable phrasing.
- Security-conscious messages: For sensitive data (emails, usernames, accounts), use generic error messages to prevent enumeration attacks.
- Type consistency: Convert checkbox values to string for consistent comparison:
$value = (string) $value.
- Type hints (recommended): Add parameter and return type hints for clarity:
function callback(string $value): string|bool.
- Descriptive names: Use clear method names like
email_unique or verify_phone_number, not check1.
- Document your callbacks: Add PHPDoc comments explaining what the callback validates and what it returns.
Always use parameterized queries or framework helpers: Use included-db-get_one_where() or included-db-query_bind() with placeholders. Never concatenate user input directly into SQL queries.
// GOOD - framework helper (automatically parameterized)
$existing = $this->db->get_one_where('email', $email, 'users');
// GOOD - parameterized query
$sql = 'SELECT id FROM users WHERE email = :email';
$result = $this->db->query_bind($sql, ['email' => $email], 'object');
// BAD - vulnerable to SQL injection
$sql = "SELECT id FROM users WHERE email = '$email'";
$result = $this->db->query($sql, 'object');
Framework Security Features: By following Trongate validation patterns, you automatically get:
- Automatic URL blocking - via
block_url()
- Pre-cleaned input - Values are processed by
post($field, true) before reaching callbacks
- Built-in SQL injection prevention - Framework helpers use parameterized queries
- CSRF protection - Automatic token validation on form submissions
- Session-based validation - Errors survive redirects and display appropriately
- Zero configuration security - Works automatically when patterns are followed