APIs RESTful en PHP 8+: DTOs, Attributes y manejo de errores práctico

php APIs RESTful en PHP 8+: DTOs, Attributes y manejo de errores práctico

APIs RESTful en PHP 8+: DTOs, Attributes y manejo de errores práctico

Este post muestra un patrón práctico para construir endpoints REST en PHP 8+: usa DTOs tipados, Attributes para validación declarativa y un manejador de errores sencillo. El objetivo es que puedas aplicar esto en microservicios o dentro de un framework propio, con poco código y buenas garantías de tipo y seguridad.

Problema

Validar y mapear peticiones JSON suele generar código repetitivo: verificaciones manuales, arrays asociativos inseguros y validaciones dispersas. Con PHP 8 puedes aprovechar tipos, atributos y constructor injection para centralizar validación y tener un flujo claro: Request → DTO → Servicio.

Patrón propuesto

  1. Define DTOs tipados (constructor readonly/typed).
  2. Decora propiedades con Attributes de validación.
  3. Mapea la entrada JSON al DTO mediante reflexión (o helper).
  4. Valida el DTO con un validador minimal y lanza excepciones HTTP para errores.

Ejemplo mínimo (sin dependencias externas)

Archivo: src/Attributes.php

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Required {}

#[Attribute(Attribute::TARGET_PROPERTY)]
final class Email {}

Archivo: src/DTOs/CreateUserDto.php

final class CreateUserDto
{
    public function __construct(
        #[Required]
        public string $name,

        #[Required]
        #[Email]
        public string $email,

        public ?int $age = null
    ){}
}

Validador y mapeador: src/Validate.php

function mapToDto(string $json, string $dtoClass): object
{
    $data = json_decode($json, true);
    if (!is_array($data)) {
        throw new HttpException(400, 'Invalid JSON');
    }

    $ref = new ReflectionClass($dtoClass);
    $ctor = $ref->getConstructor();
    $args = [];

    if ($ctor) {
        foreach ($ctor->getParameters() as $param) {
            $name = $param->getName();
            if (array_key_exists($name, $data)) {
                $args[] = $data[$name];
            } elseif ($param->isDefaultValueAvailable()) {
                $args[] = $param->getDefaultValue();
            } else {
                // dejar que el constructor falle o validar después
                $args[] = null;
            }
        }
    }

    return $ref->newInstanceArgs($args);
}

function validateDto(object $dto): array
{
    $errors = [];
    $ref = new ReflectionObject($dto);

    foreach ($ref->getProperties() as $prop) {
        $propName = $prop->getName();
        $value = $prop->getValue($dto);

        foreach ($prop->getAttributes() as $attr) {
            $name = $attr->getName();
            if ($name === Required::class) {
                if ($value === null || $value === '') {
                    $errors[$propName][] = 'required';
                }
            }

            if ($name === Email::class && $value !== null && $value !== '') {
                if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                    $errors[$propName][] = 'invalid_email';
                }
            }
        }
    }

    return $errors;
}

Manejador de excepciones HTTP simple: src/HttpException.php

class HttpException extends \RuntimeException
{
    public int $status;

    public function __construct(int $status, string $message)
    {
        parent::__construct($message);
        $this->status = $status;
    }
}

Entrypoint del endpoint: public/index.php

require 'vendor/autoload.php'; // si tienes autoload

try {
    $json = file_get_contents('php://input');
    $dto = mapToDto($json, CreateUserDto::class);

    $errors = validateDto($dto);
    if (!empty($errors)) {
        throw new HttpException(422, json_encode(['errors' => $errors]));
    }

    // Llama a tu servicio (inserción, lógica de negocio...) - ejemplo mínimo
    $newId = createUserInRepository($dto); // función ficticia

    jsonResponse(['id' => $newId], 201);

} catch (HttpException $e) {
    $status = $e->status ?? 500;
    jsonResponse(['error' => $e->getMessage()], $status);
} catch (Throwable $e) {
    // Logging real aquí
    jsonResponse(['error' => 'Internal Server Error'], 500);
}

function jsonResponse($data, int $status = 200): void
{
    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data, JSON_UNESCAPED_UNICODE);
}

Explicación rápida

  • DTOs tipados te dan validación por tipo en tiempo de ejecución y autocompletado en tu IDE.
  • Attributes permiten adjuntar reglas de validación a las propiedades, manteniendo la intención cerca del dato.
  • El mapeador usa reflexión para construir el DTO desde JSON; puedes sustituirlo por un mapper más completo (symfony serializer, spatie/data-transfer-object, etc.).
  • Lanzar una HttpException centraliza la respuesta de error y simplifica controladores.

Mejoras y consideraciones

  • Para producción, usa un validador más completo (por ejemplo, Symfony Validator) o amplía los attributes con opciones (min, max, pattern).
  • Evita mapear directamente arrays grandes: filtra keys permitidas antes de construir el DTO para evitar inyección de propiedades no deseadas.
  • Activa Opcache y, cuando corresponda, preload para reducir overhead de reflexión en endpoints críticos.
  • Si tu aplicación es de alto rendimiento, considera evitar reflexión en la ruta crítica y precompilar mappers (caché de closures o código generado).

Errores comunes

  • No validar tipos: confía en las declaraciones (typed properties) pero valida formatos (emails, fechas, enum-like).
  • Usar json_decode sin validar el resultado puede llevar a errores silenciosos.
  • Lanzar mensajes de error crudos al cliente; siempre normaliza la estructura de error y no expongas stack traces en producción.

Consejo avanzado: usa readonly DTOs y strict_types=1 en toda tu base de código, y combina attributes con generación de validators en tiempo de despliegue (un pequeño script que lea reflection y genere código PHP optimizado para validación). Esto conserva la legibilidad de attributes y evita el coste de reflexión en cada petición.

Advertencia de seguridad: nunca confíes en el input del cliente. Sanitiza lo que vayas a insertar en la base de datos, aplica rate limiting y valida límites (tamaños, tipos). Si tu API recibe archivos, valida tipo MIME en el servidor y aplica límites de tamaño estrictos.

Siguiente paso práctico: integra este patrón en tu controlador real, reemplaza el mapeador por un serializer robusto y añade tests unitarios para los DTOs y validaciones.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión