Cómo construir una API RESTful en PHP sin frameworks: tutorial paso a paso
Objetivo: crear una API REST simple con autenticación JWT, acceso a base de datos via PDO, enrutador mínimo y buenas prácticas de seguridad. Dirigido a desarrolladores PHP que quieren entender los fundamentos sin depender de frameworks.
Requisitos
- PHP 8+
- Composer
- MySQL o MariaDB
- Extensiones: pdo_mysql, openssl (para JWT)
Estructura de carpetas (sugerida)
project/
├─ public/
│ └─ index.php # único punto de entrada
├─ src/
│ ├─ Config.php
│ ├─ Database.php
│ ├─ Router.php
│ ├─ Controllers/
│ │ ├─ AuthController.php
│ │ └─ UserController.php
│ ├─ Models/
│ │ └─ User.php
│ └─ Middleware/
│ └─ AuthMiddleware.php
├─ vendor/
└─ composer.json
1) composer.json y dependencias
Usaremos firebase/php-jwt para tokens.
{
"require": {
"firebase/php-jwt": "^6.0"
},
"autoload": {
"psr-4": { "App\\": "src/" }
}
}
Ejecuta: composer install
2) Configuración (src/Config.php)
<?php
namespace App;
class Config {
public const DB_HOST = '127.0.0.1';
public const DB_NAME = 'api_db';
public const DB_USER = 'api_user';
public const DB_PASS = 'secret';
// Cambia por un secreto fuerte y mantenlo fuera del repo en producción
public const JWT_SECRET = 'cambiar_por_una_clave_muy_larga_y_segura';
public const JWT_ISSUER = 'tu-api';
public const JWT_EXP = 3600; // segundos
}
3) Conexión a base de datos (src/Database.php)
<?php
namespace App;
use PDO;
use PDOException;
class Database {
private static ?PDO $instance = null;
public static function get(): PDO
{
if (self::$instance === null) {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', Config::DB_HOST, Config::DB_NAME);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
self::$instance = new PDO($dsn, Config::DB_USER, Config::DB_PASS, $options);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Database connection failed']);
exit;
}
}
return self::$instance;
}
}
4) Modelo de usuario (src/Models/User.php)
<?php
namespace App\Models;
use App\Database;
use PDO;
class User
{
public int $id;
public string $email;
public string $password; // hashed
public static function findByEmail(string $email): ?array
{
$db = Database::get();
$stmt = $db->prepare('SELECT id, email, password FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
return $user ?: null;
}
public static function create(string $email, string $passwordHash): int
{
$db = Database::get();
$stmt = $db->prepare('INSERT INTO users (email, password) VALUES (?, ?)');
$stmt->execute([$email, $passwordHash]);
return (int)$db->lastInsertId();
}
public static function all(): array
{
$db = Database::get();
$stmt = $db->query('SELECT id, email FROM users');
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
5) Autenticación con JWT (src/Controllers/AuthController.php)
<?php
namespace App\Controllers;
use App\Config;
use App\Models\User;
use Firebase\JWT\JWT;
class AuthController
{
public static function register(array $data)
{
if (empty($data['email']) || empty($data['password'])) {
http_response_code(400);
echo json_encode(['error' => 'email y password requeridos']);
return;
}
if (User::findByEmail($data['email'])) {
http_response_code(409);
echo json_encode(['error' => 'Usuario ya existe']);
return;
}
$hash = password_hash($data['password'], PASSWORD_DEFAULT);
$id = User::create($data['email'], $hash);
http_response_code(201);
echo json_encode(['id' => $id, 'email' => $data['email']]);
}
public static function login(array $data)
{
if (empty($data['email']) || empty($data['password'])) {
http_response_code(400);
echo json_encode(['error' => 'email y password requeridos']);
return;
}
$user = User::findByEmail($data['email']);
if (!$user || !password_verify($data['password'], $user['password'])) {
http_response_code(401);
echo json_encode(['error' => 'Credenciales inválidas']);
return;
}
$now = time();
$payload = [
'iss' => Config::JWT_ISSUER,
'iat' => $now,
'exp' => $now + Config::JWT_EXP,
'sub' => $user['id'],
'email' => $user['email']
];
$token = JWT::encode($payload, Config::JWT_SECRET, 'HS256');
echo json_encode(['token' => $token, 'expires_in' => Config::JWT_EXP]);
}
}
6) Middleware de autenticación (src/Middleware/AuthMiddleware.php)
<?php
namespace App\Middleware;
use App\Config;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class AuthMiddleware
{
public static function protect()
{
$hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.*)$/i', $hdr, $m)) {
http_response_code(401);
echo json_encode(['error' => 'Token requerido']);
exit;
}
$token = $m[1];
try {
$decoded = JWT::decode($token, new Key(Config::JWT_SECRET, 'HS256'));
// se puede inyectar info del usuario en la request global si se desea
return $decoded;
} catch (\Exception $e) {
http_response_code(401);
echo json_encode(['error' => 'Token inválido: ' . $e->getMessage()]);
exit;
}
}
}
7) Router mínimo (src/Router.php)
<?php
namespace App;
class Router
{
private array $routes = [];
public function add(string $method, string $path, callable $handler)
{
$this->routes[] = [strtoupper($method), $path, $handler];
}
public function dispatch()
{
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
foreach ($this->routes as [$m, $p, $h]) {
if ($m === $method && $p === $uri) {
call_user_func($h);
return;
}
}
http_response_code(404);
echo json_encode(['error' => 'Not found']);
}
}
8) Punto de entrada (public/index.php)
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use App\Router;
use App\Controllers\AuthController;
use App\Controllers\UserController;
header('Content-Type: application/json; charset=utf-8');
// Permitir CORS para desarrollo; ajusta dominios en producción
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
$router = new Router();
$router->add('POST', '/register', function() {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
\App\Controllers\AuthController::register($data);
});
$router->add('POST', '/login', function() {
$data = json_decode(file_get_contents('php://input'), true) ?? [];
\App\Controllers\AuthController::login($data);
});
$router->add('GET', '/users', function() {
$decoded = \App\Middleware\AuthMiddleware::protect();
echo json_encode(\App\Models\User::all());
});
$router->dispatch();
9) Tabla SQL básica
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
10) Ejemplos de uso
Registro:
curl -X POST http://localhost/register \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.com","password":"secret123"}'
Login:
curl -X POST http://localhost/login \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.com","password":"secret123"}'
=> {"token":"eyJ...","expires_in":3600}
Recurso protegido:
curl http://localhost/users \
-H 'Authorization: Bearer eyJ...'
11) Buenas prácticas de seguridad y rendimiento
- Usa prepared statements (ya usamos PDO con placeholders).
- Almacena secrets fuera del repo (env vars, Vault).
- Hashea contraseñas con password_hash y verifica con password_verify.
- Reduce la superficie del token: incluir solo lo necesario y corta expiración.
- Implementa HTTPS obligatorio en producción.
- Rate limiting y protección contra brute-force (limitar intentos de login).
- Habilita cabeceras de seguridad (CSP, X-Frame-Options si aplica).
- Cachea respuestas públicas y usa query optimization para consultas pesadas.
12) Errores comunes y cómo evitarlos
- No validar input: usa validaciones y sanitización. Evita suponer que los datos vienen limpios.
- Exponer mensajes internos: no devuelvas tracebacks en producción.
- Clave JWT estática y débil: rota claves periódicamente y usa env vars.
- No usar HTTPS: tokens se exponen en texto claro.
Este es un punto de partida: la implementación es deliberadamente simple para que entiendas cada pieza. Para producción considera pruebas automatizadas, logging estructurado, monitoreo y deployment seguro (CI/CD).
Consejo avanzado: reemplaza el enrutador mínimo por FastRoute para rendimiento, y añade refresh tokens con revocación en base de datos para permitir expiraciones cortas sin forzar re-login. También considera scopes/roles en el payload JWT para control de accesos más fino.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación