User guide
Validation
Validation is application code. Inlay provides the execution lifecycle and context; it does not ship UserValidation, ProfileValidation, or any other domain-specific rules.
Install and generate
composer require inlayphp/validation
php artisan make:inlay-validation UserThe generator creates app/Validation/UserRules.php:
<?php
declare(strict_types=1);
namespace App\Validation;
use Inlay\Validation\Validation;
use Inlay\Validation\ValidationContext;
final class UserRules extends Validation
{
public function rules(ValidationContext $context): array
{
return [
// Add ordinary Laravel rules here.
];
}
}The name is neutral by design. Generate nested classes with
make:inlay-validation Billing/Invoice and use --force only to intentionally
replace an existing file.
A complete validation class
namespace App\Validation;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use Inlay\Validation\Validation;
use Inlay\Validation\ValidationContext;
final class UserRules extends Validation
{
public function prepare(array $data, ValidationContext $context): array
{
return [
...$data,
'name' => trim((string) ($data['name'] ?? '')),
'email' => strtolower(trim((string) ($data['email'] ?? ''))),
];
}
public function rules(ValidationContext $context): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'email',
Rule::unique('users', 'email')->ignore($context->record()),
],
'password' => [
$context->isOperation('create') ? 'required' : 'nullable',
'string',
'min:12',
],
];
}
public function messages(ValidationContext $context): array
{
return [
'email.unique' => 'That email address is already in use.',
];
}
public function attributes(ValidationContext $context): array
{
return ['email' => 'email address'];
}
public function after(ValidationContext $context): array
{
return [static function (Validator $validator): void {
if ($validator->safe()->string('name')->is('Admin')) {
$validator->errors()->add('name', 'Choose a different display name.');
}
}];
}
}Use prepare() for normalization, not persistence or external side effects.
Authorization belongs in policies or request authorization. Persistence belongs
in Resources, controllers, importers, or domain services.
Validation context
ValidationContext is immutable and tells one class which consumer is running:
| Method | Meaning |
|---|---|
operation() |
create, update, import, or an application operation |
source() |
form, import, api, action, or bulk |
data() |
prepared complete input |
input($path, $default) |
prepared dotted-path lookup |
record() |
current model or domain record |
user() |
authenticated actor supplied by the caller |
options() / option() |
consumer-specific context |
isOperation() / isSource() |
safe conditional checks |
$context = ValidationContext::make(
operation: 'update',
source: ValidationContext::SOURCE_FORM,
record: $user,
user: request()->user(),
options: ['workspace_id' => $user->workspace_id],
);Source constants are conventions, not a closed enum. Custom non-empty source and operation names are allowed.
Run validation directly
Use ValidationRunner when a service needs validated data:
use App\Validation\UserRules;
use Inlay\Validation\ValidationRunner;
$validated = app(ValidationRunner::class)->validate(
UserRules::class,
request()->all(),
ValidationContext::make(
operation: 'create',
source: ValidationContext::SOURCE_API,
user: request()->user(),
),
);Use make() when the native Laravel validator is needed:
$validator = app(ValidationRunner::class)->make(
UserRules::class,
$payload,
ValidationContext::make(operation: 'create'),
);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}validate() throws Laravel's ValidationException on failure. Validation
classes are resolved through the container, so constructor injection works:
final class UserRules extends Validation
{
public function __construct(private readonly PasswordPolicy $passwords) {}
public function rules(ValidationContext $context): array
{
return ['password' => $this->passwords->rules()];
}
}Forms
Attach the same class to a Form:
$form = Form::make('users.create')
->validation(UserRules::class, operation: 'create')
->precognitive(mode: 'blur', debounce: 350);The validation class is authoritative. Field rules describe the browser UX and
can be merged deliberately with mergeFieldRules() when that is appropriate.
Resources
Return the class from the Resource:
public static function validation(): string
{
return UserRules::class;
}The Resource lifecycle supplies operation, record, authenticated user, and prepared data. Authorization runs before validation and persistence receives only validated/dehydrated values.
Form Requests
UsesValidation adapts a normal Laravel Form Request:
use Illuminate\Foundation\Http\FormRequest;
use Inlay\Validation\Concerns\UsesValidation;
final class UpdateUserRequest extends FormRequest
{
use UsesValidation;
public function authorize(): bool
{
return $this->user()->can('update', $this->route('user'));
}
protected function validation(): string
{
return UserRules::class;
}
protected function validationOperation(): string
{
return 'update';
}
protected function validationRecord(): mixed
{
return $this->route('user');
}
}Override validationSource(), validationUser(), validationOptions(), or
the other protected context methods when the defaults do not fit.
Imports and actions
An importer can reuse the same validation class:
final class UserImporter extends Importer
{
public function validation(): Validation|string
{
return UserRules::class;
}
}The import source becomes SOURCE_IMPORT, so one class can add import-specific
rules without duplicating common fields. Actions and bulk actions can use the
runner for their payload forms as well.
Testing rules
Test each operation and source explicitly:
it('requires a password only on create', function (): void {
$runner = app(ValidationRunner::class);
expect(fn () => $runner->validate(
UserRules::class,
['name' => 'Ada', 'email' => 'ada@example.com'],
ValidationContext::make(operation: 'create'),
))->toThrow(ValidationException::class);
expect($runner->validate(
UserRules::class,
['name' => 'Ada', 'email' => 'ada@example.com'],
ValidationContext::make(operation: 'update'),
))->toMatchArray(['name' => 'Ada']);
});Feature-test the form, Resource, import, and API entrypoints separately. They should all reference the same class but may intentionally pass different contexts.
Keep the package boundary clean
Do not ship UserRules, ProfileRules, or tenant-specific domain rules from a
reusable package. A package may ship a base class, a runner, helper rule
objects, and a generator. The consuming application owns its concrete rules.