/ Validation Attributes

Validation Attributes

PHP 8 attributes live in Miko\Database\ORM (file ValidationAttributes.php), not a ValidationAttributes sub-namespace. Use #[Pattern] not #[Regex].

ModelValidator is an instance: new ModelValidator() then validate($model) / getErrors(). There is no static ModelValidator::validate().

ModelValidator currently enforces: Required, MaxLength, MinLength, Email, Url, Numeric, Integer, Min, Max, Range, Pattern, In, NotIn, Date, Phone, Json, Confirmed, CreditCard, UniqueValue, Exists.


Available Attributes

Attribute Description Parameters
#[Required]Field must have a valuemessage
#[MaxLength(n)]Maximum string lengthlength, message
#[MinLength(n)]Minimum string lengthlength, message
#[Email]Valid email formatmessage
#[Url]Valid URL formatmessage
#[Range(min, max)]Numeric rangemin, max, message
#[Pattern(pattern)]Match regex patternpattern, message
#[UniqueValue(table, column)]Unique in databasetable, column, ignoreId
#[In(values)]Value must be in listvalues, message
#[NotIn(values)]Value must not be in listvalues, message
#[Date]Valid date formatformat, message
#[Phone]Valid phone numbermessage

Basic Usage

Model Definition

use Miko\Database\ORM\Model;
use Miko\Database\ORM\{
    Required, MaxLength, MinLength, Email, Range, Regex, UniqueValue, In
};

class User extends Model
{
    #[Required(message: "Name is required")]
    #[MaxLength(100, message: "Name cannot exceed 100 characters")]
    public string $Name;
    
    #[Required]
    #[Email(message: "Please enter a valid email address")]
    #[MaxLength(100)]
    #[UniqueValue('users', 'Email')]
    public string $Email;
    
    #[Required]
    #[MinLength(8, message: "Password must be at least 8 characters")]
    public string $Password;
    
    #[Range(18, 120, message: "Age must be between 18 and 120")]
    public ?int $Age;
    
    #[In(['user', 'admin', 'moderator'], message: "Invalid role")]
    public string $Role = 'user';
    
    #[Pattern('/^[0-9]{10,15}$/', message: "Invalid phone number format")]
    public ?string $Phone;
}

Validating Model

use Miko\Database\ORM\ModelValidator;

$user = new User();
$user->Name = 'John';
$user->Email = 'invalid-email';
$user->Password = '123';
$user->Age = 15;

$validator = new ModelValidator();
if (!$validator->validate($user)) {

    foreach ($result->errors as $field => $messages) {
        echo "$field:\n";
        foreach ($messages as $message) {
            echo "  - $message\n";
        }
    }
}

// Output:
// Email:
//   - Please enter a valid email address
// Password:
//   - Password must be at least 8 characters
// Age:
//   - Age must be between 18 and 120

Attribute Details

#[Required]

Field must have a non-empty value.

#[Required]
public string $Name;

#[Required(message: "Email address is required")]
public string $Email;

#[MaxLength] / #[MinLength]

String length constraints.

#[MaxLength(100)]
public string $Name;

#[MinLength(8)]
public string $Password;

#[MinLength(2, message: "Name must be at least 2 characters")]
#[MaxLength(50, message: "Name cannot exceed 50 characters")]
public string $Username;

#[Email]

Validates email format.

#[Email]
public string $Email;

#[Email(message: "Please provide a valid email address")]
public string $ContactEmail;

#[Url]

Validates URL format.

#[Url]
public ?string $Website;

#[Url(message: "Please enter a valid URL")]
public ?string $ProfileUrl;

#[Range]

Numeric range validation.

#[Range(1, 100)]
public int $Quantity;

#[Range(0, 5, message: "Rating must be between 0 and 5")]
public float $Rating;

#[Range(min: 18, message: "Must be at least 18 years old")]
public int $Age;

#[Pattern]

Custom pattern matching.

#[Pattern('/^[A-Z]{2}-[0-9]{4}$/')]
public string $Code;  // Format: AB-1234

#[Pattern('/^[a-z0-9_]+$/', message: "Username can only contain lowercase letters, numbers, and underscores")]
public string $Username;

#[Pattern('/^\+?[0-9]{10,15}$/', message: "Invalid phone number")]
public ?string $Phone;

#[UniqueValue]

Database uniqueness check.

#[UniqueValue('users', 'Email')]
public string $Email;

// Ignore current record when updating
#[UniqueValue('users', 'Email', ignoreId: 'Id')]
public string $Email;

#[UniqueValue('products', 'SKU', message: "This SKU already exists")]
public string $SKU;

#[In] / #[NotIn]

Value must be (or not be) in a list.

#[In(['draft', 'published', 'archived'])]
public string $Status;

#[In(['user', 'admin', 'moderator'], message: "Invalid role selected")]
public string $Role;

#[NotIn(['admin', 'superadmin'], message: "This role is reserved")]
public string $Role;

#[Date]

Date format validation.

#[Date]
public ?string $BirthDate;

#[Date(format: 'Y-m-d', message: "Date must be in YYYY-MM-DD format")]
public string $StartDate;

#[Phone]

Phone number validation.

#[Phone]
public ?string $Phone;

#[Phone(message: "Please enter a valid phone number")]
public ?string $MobileNumber;

Combining Attributes

class Product extends Model
{
    #[Required]
    #[MinLength(3)]
    #[MaxLength(200)]
    public string $Name;
    
    #[Required]
    #[UniqueValue('products', 'SKU')]
    #[Pattern('/^[A-Z]{3}-[0-9]{5}$/', message: "SKU format: ABC-12345")]
    public string $SKU;
    
    #[Required]
    #[Range(0.01, 999999.99, message: "Price must be between $0.01 and $999,999.99")]
    public float $Price;
    
    #[Range(0, 10000)]
    public int $Stock = 0;
    
    #[In(['active', 'inactive', 'discontinued'])]
    public string $Status = 'active';
    
    #[Url]
    public ?string $ImageUrl;
    
    #[MaxLength(5000)]
    public ?string $Description;
}

Validation in API

class UserController
{
    public function store(): void
    {
        $data = json_decode(file_get_contents('php://input'), true);
        
        $user = new User();
        $user->Name = $data['name'] ?? '';
        $user->Email = $data['email'] ?? '';
        $user->Password = $data['password'] ?? '';
        $user->Age = $data['age'] ?? null;
        $user->Role = $data['role'] ?? 'user';
        
        // Validate using attributes
        $validator = new ModelValidator();
        if (!$validator->validate($user)) {
            JsonResponse::validationError($validator->getErrors());
            return;
        }
        
        // Hash password before saving
        $user->Password = password_hash($user->Password, PASSWORD_DEFAULT);
        $user->save();
        
        JsonResponse::created($user->only('Id', 'Name', 'Email', 'Role'));
    }
    
    public function update(int $id): void
    {
        $user = User::find($id);
        
        if (!$user) {
            JsonResponse::notFound('User not found');
            return;
        }
        
        $data = json_decode(file_get_contents('php://input'), true);
        
        // Update fields
        if (isset($data['name'])) $user->Name = $data['name'];
        if (isset($data['email'])) $user->Email = $data['email'];
        if (isset($data['age'])) $user->Age = $data['age'];
        
        // Validate with ignore for unique check
        $validator = new ModelValidator();
        if (!$validator->validate($user)) {
            JsonResponse::validationError($validator->getErrors());
            return;
        }
        
        $user->save();
        JsonResponse::success($user->only('Id', 'Name', 'Email', 'Role'));
    }
}

Custom Validation Attribute

Create your own validation attribute:

use Attribute;
use Miko\Database\ORM\Pattern;

#[Attribute(Attribute::TARGET_PROPERTY)]
class CreditCard extends ValidationAttribute
{
    public function __construct(
        public string $message = "Invalid credit card number"
    ) {}
    
    public function validate(mixed $value, string $propertyName): ?string
    {
        if ($value === null || $value === '') {
            return null;  // Let Required handle empty values
        }
        
        // Luhn algorithm
        $number = preg_replace('/\D/', '', $value);
        
        if (strlen($number) < 13 || strlen($number) > 19) {
            return $this->message;
        }
        
        $sum = 0;
        $length = strlen($number);
        $parity = $length % 2;
        
        for ($i = 0; $i < $length; $i++) {
            $digit = (int)$number[$i];
            
            if ($i % 2 === $parity) {
                $digit *= 2;
                if ($digit > 9) {
                    $digit -= 9;
                }
            }
            
            $sum += $digit;
        }
        
        if ($sum % 10 !== 0) {
            return $this->message;
        }
        
        return null;
    }
}

// Usage
class Payment extends Model
{
    #[Required]
    #[CreditCard(message: "Please enter a valid credit card number")]
    public string $CardNumber;
}

Validation Result

$validator = new ModelValidator();
$ok = $validator->validate($model);

if ($ok) {
    // All validations passed
}

$allErrors = $validator->getErrors();

// Get errors for specific field $emailErrors = $result->getErrors('Email'); // ['Invalid email format']

// Get first error for field $firstError = $result->getFirstError('Email'); // 'Invalid email format'

// Check if field has errors if ($result->hasErrors('Email')) { // Email has validation errors }