API REST con PHP 8: prácticas seguras y rendimiento práctico
En este artículo verás un conjunto de patrones prácticos para construir una API REST con PHP 8: conexión segura a la base de datos con PDO, autenticación JWT, validación, manejo de errores y una aproximación ligera a limitación de peticiones. Todo orientado a código utilizable en producción.
Requisitos
- PHP 8.0+ (recomendado 8.1/8.2)
- ext-pdo y driver de base de datos (p. ej. pdo_mysql)
- composer (para firebase/php-jwt)
Instalación de dependencia JWT:
composer require firebase/php-jwt
Estructura mínima
public/
index.php # punto de entrada
src/
Config.php
Database.php
Repository/UserRepository.php
Auth/AuthService.php
Middleware/AuthMiddleware.php
RateLimiter.php
1) Config y conexión PDO (segura)
declare(strict_types=1);
namespace App;
class Config {
public static string $dsn;
public static string $dbUser;
public static string $dbPass;
public static string $jwtSecret;
public static function init(): void {
self::$dsn = getenv('DB_DSN') ?: 'mysql:host=127.0.0.1;dbname=test;charset=utf8mb4';
self::$dbUser = getenv('DB_USER') ?: 'root';
self::$dbPass = getenv('DB_PASS') ?: '';
self::$jwtSecret = getenv('JWT_SECRET') ?: 'change_me';
}
}
declare(strict_types=1);
namespace App;
use PDO;
use PDOException;
class Database {
private PDO $pdo;
public function __construct() {
try {
$this->pdo = new PDO(
Config::$dsn,
Config::$dbUser,
Config::$dbPass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (PDOException $e) {
// no detalles en producción
http_response_code(500);
echo json_encode(['error' => 'DB connection error']);
exit;
}
}
public function pdo(): PDO { return $this->pdo; }
}
2) Repositorio de usuarios (prepared statements)
declare(strict_types=1);
namespace App\Repository;
use App\Database;
class UserRepository {
private \PDO $pdo;
public function __construct(Database $db) {
$this->pdo = $db->pdo();
}
public function findByEmail(string $email): ?array {
$stmt = $this->pdo->prepare('SELECT id, email, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
return $user ?: null;
}
public function createUser(string $email, string $passwordHash): int {
$stmt = $this->pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$stmt->execute([$email, $passwordHash]);
return (int)$this->pdo->lastInsertId();
}
}
3) Servicio de autenticación con JWT
Usa JWT con HS256 y una clave larga guardada fuera del repo (env/secret manager).
declare(strict_types=1);
namespace App\Auth;
use App\Config;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class AuthService {
public static function generateToken(int $userId): string {
$now = time();
$payload = [
'iat' => $now,
'exp' => $now + 3600, // 1 hora
'sub' => $userId,
];
return JWT::encode($payload, Config::$jwtSecret, 'HS256');
}
public static function verifyToken(string $jwt): ?array {
try {
$decoded = (array) JWT::decode($jwt, new Key(Config::$jwtSecret, 'HS256'));
return $decoded;
} catch (\Throwable $e) {
return null;
}
}
}
4) Middleware de autenticación (simple)
declare(strict_types=1);
namespace App\Middleware;
use App\Auth\AuthService;
class AuthMiddleware {
public static function getUserIdFromHeader(): ?int {
$hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!str_starts_with($hdr, 'Bearer ')) return null;
$token = substr($hdr, 7);
$data = AuthService::verifyToken($token);
return $data['sub'] ?? null;
}
}
5) Front controller: login y ruta protegida
// public/index.php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Config;
use App\Database;
use App\Repository\UserRepository;
use App\Auth\AuthService;
use App\Middleware\AuthMiddleware;
Config::init();
$db = new Database();
$userRepo = new UserRepository($db);
header('Content-Type: application/json; charset=utf-8');
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($method === 'POST' && $uri === '/login') {
$body = json_decode(file_get_contents('php://input'), true);
$email = $body['email'] ?? '';
$password = $body['password'] ?? '';
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !is_string($password)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input']);
exit;
}
$user = $userRepo->findByEmail($email);
if (!$user || !password_verify($password, $user['password_hash'])) {
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
exit;
}
$token = AuthService::generateToken((int)$user['id']);
echo json_encode(['token' => $token]);
exit;
}
if ($method === 'GET' && $uri === '/me') {
$userId = AuthMiddleware::getUserIdFromHeader();
if (!$userId) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// cargar datos del usuario
$stmt = $db->pdo()->prepare('SELECT id, email FROM users WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch();
echo json_encode(['user' => $user]);
exit;
}
http_response_code(404);
echo json_encode(['error' => 'Not found']);
6) Rate limiting (apróx. demo)
En producción usa Redis o un gateway (nginx, API gateway). Aquí un rate limiter por IP usando APCu si está disponible:
declare(strict_types=1);
namespace App;
class RateLimiter {
private int $limit;
private int $window;
public function __construct(int $limit = 60, int $window = 60) {
$this->limit = $limit;
$this->window = $window;
}
public function allow(string $key): bool {
if (function_exists('apcu_fetch')) {
$cacheKey = 'rl:' . $key;
$data = apcu_fetch($cacheKey);
$now = time();
if (!$data) {
apcu_store($cacheKey, ['count' => 1, 'start' => $now], $this->window);
return true;
}
if ($now - $data['start'] > $this->window) {
apcu_store($cacheKey, ['count' => 1, 'start' => $now], $this->window);
return true;
}
if ($data['count'] >= $this->limit) return false;
apcu_store($cacheKey, ['count' => $data['count'] + 1, 'start' => $data['start']], $this->window - ($now - $data['start']));
return true;
}
// fallback NO-SEGURO: permite siempre (multi-process no retenido)
return true;
}
}
7) Manejo de errores centralizado
set_exception_handler(function(\Throwable $e) {
http_response_code(500);
error_log($e->getMessage());
echo json_encode(['error' => 'Internal server error']);
});
set_error_handler(function($severity, $message, $file, $line) {
throw new ErrorException($message, 0, $severity, $file, $line);
});
Buenas prácticas rápidas
- Guarda secretos fuera del repo (env vars, vaults). No uses JWT long-lived sin refresh tokens.
- Usa password_hash() y password_verify(); configura cost si hace falta.
- Valida y sanitiza toda entrada; usa prepared statements siempre.
- Usa TLS para todo el tráfico; marca cookies secure y SameSite si usas cookies.
- Registra fallos de autenticación con límites para detectar ataques de fuerza bruta.
Siguiente paso práctico: implementa refresh tokens almacenados en base de datos (rotación y revocación), añade soporte CORS restringido y mueve el rate limiter a Redis o al nivel del API gateway.
Advertencia de seguridad: no confíes en implementaciones en memoria para rate limiting en entornos multi-process o distribuidos; tampoco expongas la clave JWT en repositorios ni uses algoritmos sin verificar el campo alg del token. Considera rotación y almacenamiento seguro de claves.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación