Validations

Client errors (missing fields, bad input) should return 422 Unprocessable Entity. Pionia throws Pionia\Exceptions\ValidationException — the exception pipeline maps it automatically.

Quick choice

| Style | When |

|——-|——|

| #[Validated] / #[ValidateField] | Declare rules on the action method — runs automatically before the body |

| rules($data, [...]) | Multiple fields inside the action — imperative or conditional |

| validate('field', $data)->… | One field, custom chain, or dynamic checks |

| $this->requires([...]) | Presence only (no format checks) — prefer required in rules() instead |

| GenericService columns | CRUD scaffolding ($createColumns, $updateColumns) |

Attach validation rules to action methods. Pionia runs them automatically in processAction() before your action body — no manual rules() call needed.

#[Validated] — multiple fields


use Pionia\Collections\Arrayable;

use Pionia\Http\Response\ApiResponse;

use Pionia\Validations\Attributes\Validated;



#[Validated(rules: [

    'email' => 'required|email',

    'password' => 'required|password|min:8',

    'password_confirmation' => 'required|confirmed:password',

    'role' => 'required|in:admin,user,guest',

])]

protected function registerAction(Arrayable $data): ApiResponse

{

    // $data is already validated — business logic only

    return response(0, 'Registered', null);

}

#[ValidateField] — repeatable, one field per attribute


use Pionia\Validations\Attributes\ValidateField;



#[ValidateField('email', 'required|email')]

#[ValidateField('password', 'required|password|min:8')]

protected function loginAction(Arrayable $data): ApiResponse

{

    return response(0, 'OK', ['token' => '…']);

}

You can combine both attributes on the same method; rules for the same field are merged.

Attributes use the same pipe syntax and custom rules as rules(). Register custom rules once via validations()->extend() or configureValidations() on a provider.

rules() — imperative validation

Call inside an action when you need conditional rules or prefer inline validation:


protected function registerAction(Arrayable $data): ApiResponse

{

    rules($data, [

        'email' => 'required|email',

        'password' => 'required|password|min:8',

        'password_confirmation' => 'required|confirmed:password',

        'role' => 'required|in:admin,user,guest',

        'age' => 'integer|min:18',

        'website' => 'nullable|url',

        'invite_code' => 'nullable|uuid',

        'tags' => 'array|min:1',

    ]);



    // business logic…

}

Array syntax works too:


rules($data, [

    'email' => ['required', 'email'],

]);

nullable / sometimes skip remaining rules when the field is missing or blank.

Available rules

| Rule | Meaning |

|——|———|

| required | Present and non-blank |

| nullable, sometimes | Optional — skip other rules when empty |

| string, integer / int, numeric, number, float, boolean / bool, array | Type checks (integer accepts JSON numeric strings) |

| email, url, ip, slug, uuid, password, date | Format validators |

| phone / phone:+254 | Phone pattern; optional country prefix |

| min:n | Min length (string), value (number), or items (array) |

| max:n | Max length, value, or items |

| between:min,max | Numeric or string length range |

| in:a,b,c | Allow-list |

| not_in:a,b,c | Deny-list |

| regex:pattern | PCRE pattern |

| confirmed:field / matches:field | Must equal another field |

| required_with:field | Required when another field is present |

| required_without:field | Required when another field is absent |

| custom | Any name registered on validations() |

All failures throw ValidationException (HTTP 422).

Custom rules

Register reusable rules once on the shared ValidationManager singleton. Attributes, rules(), and validate()->rule() all use the same registry.


use Pionia\Base\Provider\Provider;

use Pionia\Validations\ValidationContext;

use Pionia\Validations\ValidationManager;



class AppProvider extends Provider

{

    public function configureValidations(ValidationManager $validations): void

    {

        $validations->extend('kenya_phone', function (ValidationContext $ctx): void {

            if (!preg_match('/^\+254/', (string) $ctx->value())) {

                $ctx->fail('Phone must be a Kenyan number');

            }

        });

    }

}

At bootstrap


validations()->extend('sku', function (ValidationContext $ctx): void {

    if (!preg_match('/^[A-Z]{2}-\d{4}$/', (string) $ctx->value())) {

        $ctx->fail('Invalid SKU format');

    }

});

Class-based rules

Implement Pionia\Validations\Contracts\ValidationRuleContract and register the class name — the manager instantiates it once per rule:


use Pionia\Validations\Contracts\ValidationRuleContract;

use Pionia\Validations\ValidationContext;



final class EvenNumberRule implements ValidationRuleContract

{

    public function validate(ValidationContext $context): void

    {

        if (!is_numeric($context->value()) || ((int) $context->value()) % 2 !== 0) {

            $context->fail('Value must be an even number');

        }

    }

}



validations()->extend('even', EvenNumberRule::class);

Use custom rules


#[Validated(rules: ['phone' => 'required|kenya_phone'])]

protected function saveAction(Arrayable $data): ApiResponse { /* … */ }



rules($data, ['phone' => 'required|kenya_phone']);

validate('phone', $data)->required()->rule('kenya_phone');

Parameters after : are available as $ctx->parameter (e.g. tier:gold'gold').

validate() — chainable (single field)

For one-off or dynamic checks inside an action:


validate('email', $data)->required()->email();

validate('password', $data)->required()->asPassword();

validate('phone', $data)->required()->rule('kenya_phone');

validate('code', $data)->string()->between(4, 8);

Pass $data, $this, or $this->request as the second argument.

$this->requires() — presence only

Checks that keys exist and are non-blank. Does not validate format — use required in rules() or attributes instead:


$this->requires(['id']);           // quick presence check

rules($data, ['id' => 'required|integer']); // presence + format

GenericService

When $createColumns or $updateColumns are set, missing required fields throw ValidationException:


use Pionia\Http\Services\Generics\UniversalGenericService;



class PostService extends UniversalGenericService

{

    public string $table = 'posts';

    public ?array $createColumns = ['title', 'body'];

    public ?array $updateColumns = ['title', 'body?']; // title? = optional

}

Optional columns: suffix with ? (e.g. 'bio?'). File uploads use $fileColumns.

Manual throws

Prefer rules() or attributes. Use manual throws only for domain rules that do not map to field rules:


use Pionia\Exceptions\ValidationException;



if ($data->get('quantity') > $stock) {

    throw new ValidationException('Insufficient stock');

}

Exception pipeline


$app->exceptions()->dontReport(ValidationException::class);

See Exceptions.

Response shape


{

  "returnCode": 422,

  "returnMessage": "email must be a valid email address",

  "returnData": null

}

Related: Actions · Requests & responses · Generic services.