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
- Define DTOs tipados (constructor readonly/typed).
- Decora propiedades con
Attributesde validación. - Mapea la entrada JSON al DTO mediante reflexión (o helper).
- 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
HttpExceptioncentraliza 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_decodesin 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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación