Lightpack Validation

Lightpack provides robust data validation support. This can be helpful when validating form inputs or data to be stored in database.

Quick Start

In your controller's method, you can typehint Lightpack\Validation\Validator as dependency or you can call the validator() helper function.

$validator = validator();

$validator
    ->field('username')->required()->string()->min(3)->max(50)
    ->field('email')->required()->email()
    ->field('age')->int()->between(18, 100);

$validator->setInput($_POST)->validate();

if ($validator->fails()) {
    $errors = $validator->getErrors();
}

How Validation Works

  1. Define fields and rules using the fluent API.
  2. Call setInput($input) then validate() to run validation.
  3. Check results with passes(), fails(), getErrors(), or getError('field').

Sticky Forms and Errors

If the form validation fails for the current request, you would want to:

When you call validateRequest() method on the validator instance, Lightpack automatically sets the current request input data and validation error messages in the active session.

Lightpack provides two helpers:

old()

old(string $key, string|array|null $default = '', bool $escape = true): string|array

What it does: Returns the previously submitted value for a form field, or a default if not present.

When to use:

Example:

<input name="email" value="<?= old('email') ?>">

If the user submitted the form and it failed validation, their email input will be preserved.

error()

error(string $key)

What it does: Returns the validation error message for a specific field, if any.

When to use:

Example:

<input name="email" value="<?= old('email') ?>">
<span class="error"><?= error('email') ?></span>

If validation fails for email, the error message appears next to the field.

Example

Below is an example showing usage of above two helper functions.

Controller

public function register(Request $request)
{
    $validator = validator()
        ->field('username')->required()->min(3)
        ->field('email')->required()->email()
        ->field('password')->required()->min(8);

    $validator->validateRequest();

    if ($validator->fails()) {
        return redirect()->back();
    }

    // ... proceed with registration
}

View

<form method="POST">
    <?= csrf_input() ?>

    <label>Username</label>
    <input name="username" value="<?= old('username') ?>">
    <span class="error"><?= error('username') ?></span>

    <label>Email</label>
    <input name="email" value="<?= old('email') ?>">
    <span class="error"><?= error('email') ?></span>

    <label>Password</label>
    <input name="password" type="password">
    <span class="error"><?= error('password') ?></span>

    <button type="submit">Register</button>
</form>

Form Requests

The FormRequest class in Lightpack provides a powerful, expressive, and reusable way to handle HTTP request validation and authorization in your applications. It encapsulates validation logic, error handling, and request data preparation, making your controllers clean and focused.


Key Features


How It Works

  1. Extend FormRequest: Create your own request classes by extending Lightpack\Http\FormRequest.
  2. Define Rules: Implement the abstract rules() method to configure your validation rules.
    • Rule Resolution: Your rules() method is called via the container.
    • You can typehint dependencies you would like to get injected by the framework.
  3. Automatic Bootstrapping: Lightpack boots your FormRequest, runs validation, and handles errors or passes control to your controller.
  4. On successful validation, controller method executes further.
  5. On Failure:

Example Usage

1. Create a FormRequest

php console create:request RegisterUserRequest

Then implement the rules() method. For example:

namespace App\Requests;

use Lightpack\Http\FormRequest;

class RegisterUserRequest extends FormRequest
{
    protected function rules()
    {
         $this->validator
            ->field('name')
            ->required()
            ->max(255);

         $this->validator
            ->field('email')
            ->required()
            ->email()
            ->custom(new UniqueEmailRule, UniqueEmailRule::MESSAGE);

         $this->validator
            ->field('password')
            ->required()
            ->min(6)
            ->max(25);

         $this->validator
            ->field('confirm_password')
            ->required()
            ->same('password');
    }
}

2. Use in Controller

Typehint the request class as dependency in your controller's method:

public function register(RegisterUserRequest $request)
{
   // If validation passes, you reach here!

    $data = $request->all();

   // Proceed with user registration...
}

Overridable Hooks

You can customize the request lifecycle by overriding these methods:

Override any of the following in your FormRequest:

protected function data()
{
   // Manipulate request input data before validation
}

protected function beforeSend()
{
   // Add custom headers or logging before JSON error response
}

protected function beforeRedirect()
{
   // Custom logic before redirecting on failure
}

Available Rules

String Rules

Numeric Rules

Date/Time Rules

Boolean Rules

Array Rules

Comparison Rules

Conditional Rules

Database Rules

File & Image Rules

Password Strength Rules

Custom & Advanced Rules


Wildcards & Nested Validation

Validate arrays of objects or deeply nested data with wildcards:

$validator
    ->field('users.*.email')->required()->email()
    ->field('users.*.roles')->array()->in(['admin', 'user']);

$input = [
    'users' => [
        ['email' => 'john@example.com', 'roles' => ['admin']],
        ['email' => 'jane@example.com', 'roles' => ['user']],
    ]
];

$validator->setInput($input)->validate();

Wildcard Behavior:

Example with array validation:

$validator
    ->field('items')->required()->array(1)  // Must have at least 1 item
    ->field('items.*')->required()->numeric()->min(1);  // Each item validated

Error Messages for Nested/Wildcard Fields

Default Messages: When validation fails for nested fields, the error key includes the full path with correct array indices (0-based):

$data = [
    'invoice' => [
        'items' => [
            ['product' => 'Item 1', 'quantity' => '5', 'price' => '100'],
            ['product' => '', 'quantity' => 'abc', 'price' => '200'],
        ]
    ]
];

$validator
    ->field('invoice.items.*.product')->required()->string()->min(3)
    ->field('invoice.items.*.quantity')->required()->int()->min(1)
    ->field('invoice.items.*.price')->required()->numeric()->min(0);

$validator->setInput($data)->validate();

// Error keys will be:
// 'invoice.items.1.product' => 'This field is required'
// 'invoice.items.1.quantity' => 'Must be an integer'

Custom Messages: You can customize messages for wildcard fields just like regular fields:

$validator
    ->field('invoice.items.*.product')
    ->required()
    ->message('Product name is required')
    ->string()
    ->min(3)
    ->message('Product name must be at least 3 characters')

    ->field('invoice.items.*.quantity')
    ->required()
    ->message('Quantity is required')
    ->int()
    ->message('Quantity must be a whole number')
    ->min(1)
    ->message('Quantity must be at least 1')

    ->field('invoice.items.*.price')
    ->required()
    ->message('Price is required')
    ->numeric()
    ->message('Price must be a number')
    ->min(0)
    ->message('Price cannot be negative');

// Errors will use your custom messages:
// 'invoice.items.1.product' => 'Product name is required'
// 'invoice.items.1.quantity' => 'Quantity must be a whole number'

Displaying Errors in Views:

<form method="POST">
    <?php foreach ($invoice['items'] as $index => $item): ?>
        <div class="item">
            <input name="invoice[items][<?= $index ?>][product]" 
                   value="<?= old("invoice.items.{$index}.product") ?>">
            <span class="error">
                <?= error("invoice.items.{$index}.product") ?>
            </span>

            <input name="invoice[items][<?= $index ?>][quantity]" 
                   value="<?= old("invoice.items.{$index}.quantity") ?>">
            <span class="error">
                <?= error("invoice.items.{$index}.quantity") ?>
            </span>

            <input name="invoice[items][<?= $index ?>][price]" 
                   value="<?= old("invoice.items.{$index}.price") ?>">
            <span class="error">
                <?= error("invoice.items.{$index}.price") ?>
            </span>
        </div>
    <?php endforeach; ?>
</form>

File & Image Validation

$validator->field('avatar')
    ->file()
    ->fileSize('1M')
    ->fileType(['image/jpeg', 'image/png'])
    ->image([
        'min_width' => 100,
        'max_width' => 1000,
        'min_height' => 100,
        'max_height' => 1000
    ]);

Multiple files:

$validator->field('photos')
    ->multipleFiles(1, 5)
    ->fileSize('2M')
    ->fileType(['image/jpeg', 'image/png']);

Error Handling & Messages

Custom error messages

$validator->field('age')
    ->numeric()
    ->message('Age must be a number')
    ->between(18, 100)
    ->message('Age must be between 18 and 100');

Example: Password Strength

$validator->field('password')
    ->required()
    ->between(8, 32)
    ->hasUppercase()
    ->hasLowercase()
    ->hasNumber()
    ->hasSymbol();

Example: User Registration

$validator
    ->field('username')->required()->string()->min(3)->max(50)->alphaNum()
    ->field('email')->required()->email()
    ->field('password')->required()->between(8, 32)->hasUppercase()->hasLowercase()->hasNumber()->hasSymbol()
    ->field('avatar')->image([
        'max_width' => 1000,
        'max_height' => 1000
    ]);

Nested & Conditional Validation

Nested Data Validation

$validator
    ->field('user.profile.name')->required()
    ->field('user.profile.age')->required()->int()->custom(fn($v) => $v >= 18, 'Must be 18 or older');

$validator
    // Only one address can be primary
    ->field('user.addresses')->custom(function($addresses) {
        $primaryCount = 0;
        foreach ($addresses as $address) {
            if ($address['is_primary']) {
                $primaryCount++;
            }
        }
        return $primaryCount === 1;
    }, 'Only one address can be marked as primary');

Conditional Validation

requiredIf() - Required when another field has specific value:

// Reason required when status is rejected
$validator
    ->field('status')->required()->in(['pending', 'approved', 'rejected'])
    ->field('reason')->requiredIf('status', 'rejected')->min(20);

// Company name required for business accounts
$validator
    ->field('account_type')->required()->in(['personal', 'business'])
    ->field('company_name')->requiredIf('account_type', 'business');

// Works with nested fields
$validator
    ->field('user.type')->required()
    ->field('company_details.name')->requiredIf('user.type', 'business');

requiredWith() - Required when other fields are present:

// Phone required if country code is provided
$validator
    ->field('country_code')->string()
    ->field('phone')->requiredWith('country_code')->numeric();

// Address fields work together
$validator
    ->field('city')->string()
    ->field('state')->string()
    ->field('address')->requiredWith(['city', 'state']);

requiredWithout() - Required when other fields are NOT present:

// Email required if phone is not provided (at least one contact method)
$validator
    ->field('email')->requiredWithout('phone')->email()
    ->field('phone')->requiredWithout('email')->numeric();

// Billing address required if not using saved address
$validator
    ->field('billing_address')->requiredWithout('use_saved_address');

requiredUnless() - Required unless another field has specific value:

// Shipping address required unless same as billing
$validator
    ->field('same_as_billing')->bool()
    ->field('shipping_address')->requiredUnless('same_as_billing', true);

// Reason required unless status is approved
$validator
    ->field('status')->required()->in(['approved', 'rejected', 'pending'])
    ->field('reason')->requiredUnless('status', 'approved')->min(10);

Database Uniqueness Validation

Use dbUnique() to check if values are unique in the database:

Single Column Uniqueness:

// Email must be unique in users table
$validator
    ->field('email')
    ->required()
    ->email()
    ->dbUnique('users', 'email');

// Username must be unique
$validator
    ->field('username')
    ->required()
    ->alphaNum()
    ->dbUnique('users', 'username');

Composite Uniqueness:

// Email must be unique per organization
$validator
    ->field('email')
    ->required()
    ->email()
    ->dbUnique('users', ['email', 'organization_id']);

// Slug must be unique per category
$validator
    ->field('slug')
    ->required()
    ->slug()
    ->dbUnique('posts', ['slug', 'category_id']);

// SKU must be unique per warehouse
$validator
    ->field('sku')
    ->required()
    ->dbUnique('inventory', ['sku', 'warehouse_id']);

Ignoring Records (For Updates):

// Ignore current user when updating email
$validator
    ->field('email')
    ->required()
    ->email()
    ->dbUnique('users', 'email', ignoreId: $userId);

// Ignore current post when updating slug
$validator
    ->field('slug')
    ->required()
    ->slug()
    ->dbUnique('posts', ['slug', 'category_id'], ignoreId: $postId);

Custom ID Column:

// For tables using UUID or custom primary keys
$validator
    ->field('code')
    ->required()
    ->dbUnique('products', 'code', ignoreId: $uuid, idColumn: 'uuid');

Database Existence Validation

Use exists() to verify that values exist in the database (e.g., foreign key validation):

Single Column Check:

// Category ID must exist in categories table
$validator
    ->field('category_id')
    ->required()
    ->int()
    ->exists('categories', 'id');

// User ID must exist
$validator
    ->field('user_id')
    ->required()
    ->exists('users', 'id');

// Email must exist (for login/password reset)
$validator
    ->field('email')
    ->required()
    ->email()
    ->exists('users', 'email');

With Additional Conditions:

// Category must exist AND be active
$validator
    ->field('category_id')
    ->required()
    ->exists('categories', 'id', where: ['status' => 'active']);

// User must exist and not be banned
$validator
    ->field('user_id')
    ->required()
    ->exists('users', 'id', where: [
        'status' => 'active',
        'banned' => false
    ]);

// Product must exist in specific warehouse
$validator
    ->field('product_id')
    ->required()
    ->exists('products', 'id', where: ['warehouse_id' => $warehouseId]);

Composite Column Check:

// Check if SKU exists in specific warehouse
$validator
    ->field('sku')
    ->required()
    ->exists('inventory', ['sku', 'warehouse_id']);

// Validates: SELECT COUNT(*) FROM inventory 
//            WHERE sku = ? AND warehouse_id = ?

// Input data must contain both fields:
$validator->setInput([
    'sku' => 'PROD-001',
    'warehouse_id' => 5
]);

Default Column (uses field name):

// If no column specified, uses the field name
$validator
    ->field('id')
    ->required()
    ->exists('categories'); // Checks 'id' column automatically

Practical Examples:

// Order form validation
$validator
    ->field('customer_id')
    ->required()
    ->exists('customers', 'id', where: ['status' => 'active'])

    ->field('product_id')
    ->required()
    ->exists('products', 'id', where: ['in_stock' => true])

    ->field('shipping_method')
    ->required()
    ->exists('shipping_methods', 'code', where: ['enabled' => true]);

// Assignment validation
$validator
    ->field('user_id')
    ->required()
    ->exists('users', 'id', where: ['role' => 'employee'])

    ->field('project_id')
    ->required()
    ->exists('projects', 'id', where: ['status' => 'active']);

Custom Rules & Transformers

Register a custom rule globally

$validator->addRule('uppercase', function($value) {
    return strtoupper($value) === $value;
}, 'Must be uppercase');

$validator->field('code')->uppercase();

Custom rule per field

$validator->field('code')->custom(function($value) {
    return preg_match('/^CODE-\\d{6}$/', $value);
}, 'Invalid code format');

Transform values before validation

$validator->field('tags')
    ->transform(fn($v) => explode(',', $v))
    ->array();

Class-Based Custom Rules

For advanced scenarios, you can use invokable classes as custom validation rules. This is ideal for business logic that needs dependencies, database access, or configuration.

Example: Unique Email Rule

namespace App\Rules;

use App\Models\User;

class UniqueEmailRule
{
    public const MESSAGE = 'Email already exists';

    public function __construct(
        private ?int $excludeId = null
    ) {}

    public function __invoke(string $email): bool
    {
        return User::query()
            ->where('email', '=', $email)
            ->whereIf($this->excludeId, 'id', '!=', $this->excludeId)
            ->notExists();
    }
}

Usage:

$validator->field('email')
    ->required()
    ->email()
    ->custom(new UniqueEmailRule, UniqueEmailRule::MESSAGE);