Validação e sanitização de entrada no Back-end com Slim Framework

Capítulo 6

Tempo estimado de leitura: 10 minutos

+ Exercício

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:

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

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)

RegraExemplosObservações
Obrigatórioemail, passwordFalha se ausente, vazio ou nulo (defina o que é “vazio”).
Formatoemail, UUID, regexPrefira validadores específicos (ex.: filter_var para email).
Tamanhoname 2..80Use mb_strlen para strings com acentos.
Enumstatus in [active, blocked]Centralize a lista para evitar divergência.
Número / intervalopage >= 1Converta com segurança antes de comparar.
DatasstartDate <= endDateParse 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.: page como 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 createFromFormat e 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.

Agora responda o exercício sobre o conteúdo:

Ao lidar com entradas em uma API, qual abordagem mantém a action mais limpa e garante consistência no processamento de dados?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Validadores ou middlewares podem converter/sanitizar, validar regras e gerar um DTO. Assim, a action fica fina, sem ifs de validação espalhados, e o tratamento de erro pode seguir um padrão consistente.

Próximo capitúlo

Tratamento e padronização de erros no Slim Framework: exceções e respostas consistentes

Arrow Right Icon
Capa do Ebook gratuito Back-end com Slim Framework (PHP): Rotas, Middlewares e Arquitetura Limpa
38%

Back-end com Slim Framework (PHP): Rotas, Middlewares e Arquitetura Limpa

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.