Conceitos: validar vs. sanitizar
Validação verifica se a entrada atende regras do domínio e do contrato da API (obrigatório, tipo, formato, tamanho, enum, datas, limites). Se falhar, a requisição deve ser rejeitada com erro claro.
Sanitização transforma a entrada para um formato seguro e consistente sem mudar o significado (trim, normalização, conversões seguras, remoção de caracteres invisíveis). Sanitizar não substitui validar: você sanitiza para reduzir ruído e valida para garantir regras.
Em APIs, você normalmente valida e sanitiza três fontes: path params (ex.: /users/{id}), query params (ex.: ?page=2) e body JSON (ex.: {"email":"..."}).
Padrão de erro de validação
Defina um formato consistente para facilitar consumo no front-end e observabilidade. Um padrão simples:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Um ou mais campos são inválidos.",
"fields": {
"email": ["Formato inválido"],
"age": ["Deve ser um inteiro entre 18 e 120"]
}
}
}Recomendações:
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
- HTTP 422 (Unprocessable Entity) para payload semanticamente inválido.
- HTTP 400 para JSON malformado ou parâmetros impossíveis de interpretar.
- fields como mapa
campo -> lista de mensagens(permite múltiplas regras por campo). - code estável (para automação e i18n); message humana.
Regras comuns (checklist)
| Regra | Exemplos | Observações |
|---|---|---|
| Obrigatório | email, password | Falha se ausente, vazio ou nulo (defina o que é “vazio”). |
| Formato | email, UUID, regex | Prefira validadores específicos (ex.: filter_var para email). |
| Tamanho | name 2..80 | Use mb_strlen para strings com acentos. |
| Enum | status in [active, blocked] | Centralize a lista para evitar divergência. |
| Número / intervalo | page >= 1 | Converta com segurança antes de comparar. |
| Datas | startDate <= endDate | Parse explícito (ex.: Y-m-d) e timezone definido. |
Sanitização prática (trim, normalização, conversões seguras)
Funções utilitárias de sanitização
Crie funções pequenas e previsíveis. Evite “sanitizar demais” (ex.: remover caracteres que mudam significado). Exemplos:
<?php
final class Sanitizer
{
public static function trim(?string $value): ?string
{
if ($value === null) return null;
$v = trim($value);
return $v === '' ? null : $v;
}
public static function toInt($value): ?int
{
if ($value === null || $value === '') return null;
if (is_int($value)) return $value;
if (is_string($value) && preg_match('/^-?\d+$/', $value)) {
return (int) $value;
}
return null;
}
public static function toBool($value): ?bool
{
if ($value === null || $value === '') return null;
if (is_bool($value)) return $value;
if (is_string($value)) {
$v = strtolower(trim($value));
if (in_array($v, ['1','true','yes','on'], true)) return true;
if (in_array($v, ['0','false','no','off'], true)) return false;
}
if (is_int($value)) return $value === 1 ? true : ($value === 0 ? false : null);
return null;
}
public static function normalizeEmail(?string $value): ?string
{
$v = self::trim($value);
return $v === null ? null : mb_strtolower($v);
}
}Note que toInt retorna null quando não consegue converter com segurança. Isso evita “coerções silenciosas” que mascaram erros.
Validação de query params (ex.: paginação e filtros)
Cenário
Endpoint: GET /users?page=1&perPage=20&status=active
Passo a passo
- Extrair query params do request.
- Sanitizar/converter tipos (inteiros, enums).
- Validar regras (obrigatório, intervalo, enum).
- Se OK, disponibilizar dados validados para a action.
DTO de entrada validada
<?php
final class ListUsersQuery
{
public function __construct(
public int $page,
public int $perPage,
public ?string $status,
) {}
}Validador dedicado
<?php
final class ValidationException extends RuntimeException
{
public function __construct(public array $fields)
{
parent::__construct('VALIDATION_ERROR');
}
}
final class ListUsersQueryValidator
{
private const ALLOWED_STATUS = ['active', 'blocked'];
public function validate(array $queryParams): ListUsersQuery
{
$page = Sanitizer::toInt($queryParams['page'] ?? null) ?? 1;
$perPage = Sanitizer::toInt($queryParams['perPage'] ?? null) ?? 20;
$status = Sanitizer::trim($queryParams['status'] ?? null);
$errors = [];
if ($page < 1) {
$errors['page'][] = 'Deve ser um inteiro maior ou igual a 1.';
}
if ($perPage < 1 || $perPage > 100) {
$errors['perPage'][] = 'Deve ser um inteiro entre 1 e 100.';
}
if ($status !== null && !in_array($status, self::ALLOWED_STATUS, true)) {
$errors['status'][] = 'Valor inválido. Use: active, blocked.';
}
if ($errors) {
throw new ValidationException($errors);
}
return new ListUsersQuery($page, $perPage, $status);
}
}Uso na action (mantendo-a limpa)
A action apenas consome o DTO validado (sem ifs de validação espalhados):
<?php
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
final class ListUsersAction
{
public function __construct(private ListUsersQueryValidator $validator) {}
public function __invoke(Request $request, Response $response): Response
{
$dto = $this->validator->validate($request->getQueryParams());
// $dto->page, $dto->perPage, $dto->status
// ... chamar serviço de aplicação / repositório
$payload = ['data' => [], 'meta' => ['page' => $dto->page]];
$response->getBody()->write(json_encode($payload));
return $response->withHeader('Content-Type', 'application/json');
}
}Validação de path params (ex.: IDs e UUID)
Cenário
Endpoint: GET /users/{id} onde id deve ser inteiro positivo.
Estratégia
Path params normalmente são obrigatórios. Valide tipo e faixa. Para UUID, valide com regex ou biblioteca.
<?php
final class PathParams
{
public static function requireIntId(array $args, string $key = 'id'): int
{
$id = Sanitizer::toInt($args[$key] ?? null);
if ($id === null || $id <= 0) {
throw new ValidationException([$key => ['Deve ser um inteiro positivo.']]);
}
return $id;
}
public static function requireUuid(array $args, string $key): string
{
$v = Sanitizer::trim($args[$key] ?? null);
$ok = is_string($v) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $v);
if (!$ok) {
throw new ValidationException([$key => ['Deve ser um UUID válido.']]);
}
return $v;
}
}Na action:
<?php
final class GetUserAction
{
public function __invoke(Request $request, Response $response, array $args): Response
{
$id = PathParams::requireIntId($args, 'id');
// ... usar $id
$response->getBody()->write(json_encode(['data' => ['id' => $id]]));
return $response->withHeader('Content-Type', 'application/json');
}
}Validação de body JSON (ex.: criação de recurso)
Cenário
Endpoint: POST /users com JSON:
{
"name": " Ana ",
"email": "ANA@EXAMPLE.COM",
"birthDate": "1999-10-20",
"role": "admin"
}Passo a passo
- Ler o body como string e decodificar JSON.
- Rejeitar JSON inválido (400).
- Sanitizar campos (trim, lowercase em email).
- Validar regras (obrigatório, tamanho, formato, enum, datas).
- Produzir DTO de comando (input) para a camada de aplicação.
Leitura segura do JSON
<?php
final class JsonBody
{
public static function decode(Request $request): array
{
$raw = (string) $request->getBody();
if ($raw === '') return [];
try {
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
// JSON malformado: 400
throw new InvalidArgumentException('INVALID_JSON');
}
if (!is_array($data)) {
throw new InvalidArgumentException('INVALID_JSON');
}
return $data;
}
}DTO e validador do body
<?php
final class CreateUserInput
{
public function __construct(
public string $name,
public string $email,
public DateTimeImmutable $birthDate,
public string $role,
) {}
}
final class CreateUserValidator
{
private const ALLOWED_ROLES = ['admin', 'member'];
public function validate(array $body): CreateUserInput
{
$name = Sanitizer::trim($body['name'] ?? null);
$email = Sanitizer::normalizeEmail($body['email'] ?? null);
$birthDateRaw = Sanitizer::trim($body['birthDate'] ?? null);
$role = Sanitizer::trim($body['role'] ?? null);
$errors = [];
if ($name === null) {
$errors['name'][] = 'Campo obrigatório.';
} elseif (mb_strlen($name) < 2 || mb_strlen($name) > 80) {
$errors['name'][] = 'Deve ter entre 2 e 80 caracteres.';
}
if ($email === null) {
$errors['email'][] = 'Campo obrigatório.';
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors['email'][] = 'Formato inválido.';
}
$birthDate = null;
if ($birthDateRaw === null) {
$errors['birthDate'][] = 'Campo obrigatório.';
} else {
$birthDate = DateTimeImmutable::createFromFormat('Y-m-d', $birthDateRaw);
$formatOk = $birthDate instanceof DateTimeImmutable && $birthDate->format('Y-m-d') === $birthDateRaw;
if (!$formatOk) {
$errors['birthDate'][] = 'Use o formato YYYY-MM-DD.';
} else {
$today = new DateTimeImmutable('today');
if ($birthDate > $today) {
$errors['birthDate'][] = 'Não pode ser uma data futura.';
}
}
}
if ($role === null) {
$errors['role'][] = 'Campo obrigatório.';
} elseif (!in_array($role, self::ALLOWED_ROLES, true)) {
$errors['role'][] = 'Valor inválido. Use: admin, member.';
}
if ($errors) {
throw new ValidationException($errors);
}
return new CreateUserInput(
name: $name,
email: $email,
birthDate: $birthDate,
role: $role,
);
}
}Centralizando validação para manter actions testáveis
Há dois padrões comuns para centralizar validação e evitar repetição:
- Serviços/Validadores (como nos exemplos): a action chama um validador e recebe um DTO pronto.
- Middleware de validação: valida antes de chegar na action e injeta o DTO validado no request (atributos).
Padrão com middleware: injetando DTO no request
Crie um middleware genérico que recebe um validador e um “builder” de dados (query, args, body):
<?php
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
final class ValidateMiddleware implements MiddlewareInterface
{
public function __construct(
private string $attributeName,
private callable $dataProvider,
private object $validator,
private string $method = 'validate'
) {}
public function process(Request $request, RequestHandlerInterface $handler): Response
{
$data = ($this->dataProvider)($request);
$dto = $this->validator->{$this->method}($data);
return $handler->handle($request->withAttribute($this->attributeName, $dto));
}
}Exemplo de uso para validar query params em uma rota:
<?php
$validateListUsers = new ValidateMiddleware(
attributeName: 'listUsersQuery',
dataProvider: fn(Request $r) => $r->getQueryParams(),
validator: $container->get(ListUsersQueryValidator::class)
);
// Na definição da rota, anexar o middleware (a forma exata depende de como você registra rotas)
// ->add($validateListUsers)Na action, você apenas lê o atributo:
<?php
final class ListUsersAction
{
public function __invoke(Request $request, Response $response): Response
{
/** @var ListUsersQuery $dto */
$dto = $request->getAttribute('listUsersQuery');
// ... usar $dto
$response->getBody()->write(json_encode(['data' => [], 'meta' => ['page' => $dto->page]]));
return $response->withHeader('Content-Type', 'application/json');
}
}Middleware para validar body JSON
Combine o JsonBody::decode com o middleware:
<?php
$validateCreateUser = new ValidateMiddleware(
attributeName: 'createUserInput',
dataProvider: fn(Request $r) => JsonBody::decode($r),
validator: $container->get(CreateUserValidator::class)
);
// rota POST /users ->add($validateCreateUser)Tratamento centralizado de erros (mapeando exceções para o padrão)
Para manter consistência, converta exceções de validação em respostas JSON no formato padrão. Uma abordagem é ter um componente que transforma exceções em payloads. Exemplo de função utilitária:
<?php
final class ValidationErrorResponse
{
public static function payload(array $fields): array
{
return [
'error' => [
'code' => 'VALIDATION_ERROR',
'message' => 'Um ou mais campos são inválidos.',
'fields' => $fields,
]
];
}
}Quando capturar ValidationException, retorne:
<?php
$payload = ValidationErrorResponse::payload($e->fields);
$response->getBody()->write(json_encode($payload));
return $response
->withStatus(422)
->withHeader('Content-Type', 'application/json');Para JSON inválido (INVALID_JSON), retorne 400 com um erro sem fields ou com fields apontando body:
<?php
{
"error": {
"code": "INVALID_JSON",
"message": "O corpo da requisição não é um JSON válido.",
"fields": {
"body": ["JSON malformado"]
}
}
}Boas práticas para manter consistência e evitar bugs
- Converta tipos antes de validar (ex.:
pagecomo int). Validação de intervalo em string gera armadilhas. - Defina defaults com clareza (ex.:
page=1,perPage=20) e valide limites máximos. - Não confie em coerção automática: se não dá para converter com segurança, trate como inválido.
- Datas: use
createFromFormate confira o round-trip (format()) para garantir o formato exato. - Enum: mantenha lista permitida em constante/objeto de valor para evitar divergência entre endpoints.
- Erros por campo: acumule erros e retorne tudo de uma vez (melhor UX do consumidor da API).
- Actions finas: actions devem receber DTOs validados (via middleware ou serviço) e delegar regra de negócio para a camada apropriada.