Cómo construir una API RESTful en PHP sin frameworks: tutorial paso a paso

php Cómo construir una API RESTful en PHP sin frameworks: tutorial paso a paso

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.

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