Skip to content

Router con URLs amigables ​

Pasar de producto.php?id=15 a /productos/taza-de-ceramica mejora el SEO, se ve más profesional y te permite cambiar archivos internos sin romper enlaces. La técnica se llama front controller: Apache manda todas las peticiones a index.php y un router decide qué mostrar.

Estructura ​

txt
mi-sitio/
├── .htaccess
├── index.php           ← front controller
├── includes/
│   ├── Router.php
│   └── ...
├── vistas/
│   ├── layout.php
│   ├── inicio.php
│   ├── producto.php
│   ├── contacto.php
│   └── 404.php
└── assets/             ← CSS, JS, imágenes (se sirven directo)

.htaccess ​

apache
# .htaccess en la raíz del sitio
Options -Indexes
RewriteEngine On

# (Opcional) Forzar HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

# (Opcional) Quitar "www"
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ https://%1%{REQUEST_URI} [L,R=301]

# Quitar la barra final: /contacto/ -> /contacto
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+)/$ /$1 [L,R=301]

# Si el archivo o carpeta existe (CSS, imágenes...), servirlo tal cual
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Todo lo demás va al front controller
RewriteRule ^ index.php [QSA,L]

# Bloquea el acceso directo a carpetas internas
RewriteRule ^(includes|vistas|vendor)/ - [F,L]

Si tu sitio está en una subcarpeta

Por ejemplo localhost/mi-sitio/: agrega RewriteBase /mi-sitio/ debajo de RewriteEngine On y en index.php define BASE = '/mi-sitio'.

Router ​

php
<?php
final class Router
{
    private array $rutas = [];
    private $noEncontrado;

    public function get(string $patron, callable $accion): self  { return $this->ruta('GET', $patron, $accion); }
    public function post(string $patron, callable $accion): self { return $this->ruta('POST', $patron, $accion); }

    public function ruta(string $metodo, string $patron, callable $accion): self
    {
        // {slug} = letras, números y guiones; {id:\d+} = patrón personalizado
        $regex = preg_replace_callback('#\{(\w+)(?::([^}]+))?\}#', function ($m) {
            return '(?P<' . $m[1] . '>' . ($m[2] ?? '[a-z0-9-]+') . ')';
        }, $patron);
        $this->rutas[] = [$metodo, '#^' . $regex . '$#i', $accion];
        return $this;
    }

    public function noEncontrado(callable $accion): void
    {
        $this->noEncontrado = $accion;
    }

    public function despachar(string $base = ''): void
    {
        $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
        $uri = '/' . trim(substr($uri, strlen($base)), '/');
        $metodo = $_SERVER['REQUEST_METHOD'];

        foreach ($this->rutas as [$m, $regex, $accion]) {
            if ($m === $metodo && preg_match($regex, $uri, $coinc)) {
                $params = array_filter($coinc, 'is_string', ARRAY_FILTER_USE_KEY);
                $accion(...$params);
                return;
            }
        }
        http_response_code(404);
        ($this->noEncontrado ?? fn() => print('404'))();
    }
}

/** Renderiza una vista dentro del layout, pasando variables */
function vista(string $nombre, array $datos = []): void
{
    extract($datos, EXTR_SKIP);
    ob_start();
    require __DIR__ . "/../vistas/$nombre.php";
    $contenido = ob_get_clean();
    require __DIR__ . '/../vistas/layout.php';
}

/** URL absoluta dentro del sitio: url('productos/taza') */
function url(string $ruta = ''): string
{
    return BASE . '/' . ltrim($ruta, '/');
}

Front controller con las rutas ​

php
<?php
declare(strict_types=1);

const BASE = ''; // '/mi-sitio' si está en una subcarpeta

require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/helpers.php';
require __DIR__ . '/includes/Router.php';

$r = new Router();

$r->get('/', function () {
    $destacados = db()->query('SELECT nombre, slug, precio FROM productos WHERE activo = 1 ORDER BY id DESC LIMIT 6')->fetchAll();
    vista('inicio', ['titulo' => 'Inicio', 'destacados' => $destacados]);
});

$r->get('/productos/{slug}', function (string $slug) {
    $stmt = db()->prepare('SELECT * FROM productos WHERE slug = ? AND activo = 1');
    $stmt->execute([$slug]);
    $producto = $stmt->fetch();
    if (!$producto) {
        http_response_code(404);
        vista('404', ['titulo' => 'No encontrado']);
        return;
    }
    vista('producto', ['titulo' => $producto['nombre'], 'producto' => $producto]);
});

// Patrón personalizado (no uses llaves dentro del patrón, p. ej. \d{4})
$r->get('/blog/{anio:\d+}/{slug}', function (string $anio, string $slug) {
    vista('post', compact('anio', 'slug') + ['titulo' => 'Blog']);
});

$r->get('/contacto', fn() => vista('contacto', ['titulo' => 'Contacto']));
$r->post('/contacto', function () {
    csrf_check();
    // ... procesar como en el snippet de formulario de contacto
    flash('ok', 'Mensaje enviado');
    redirect(url('contacto'));
});

$r->noEncontrado(fn() => vista('404', ['titulo' => 'Página no encontrada']));

$r->despachar(BASE);

Vistas ​

php
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title><?= e($titulo ?? 'Mi sitio') ?> | Mi Negocio</title>
  <link rel="stylesheet" href="<?= url('assets/css/estilos.css') ?>">
</head>
<body>
  <nav>
    <a href="<?= url() ?>">Inicio</a>
    <a href="<?= url('contacto') ?>">Contacto</a>
  </nav>
  <main><?= $contenido ?></main>
  <footer>© <?= date('Y') ?> Mi Negocio</footer>
</body>
</html>
php
<article>
  <h1><?= e($producto['nombre']) ?></h1>
  <p class="precio">$<?= number_format((float) $producto['precio'], 2) ?></p>
  <div><?= nl2br(e($producto['descripcion'] ?? '')) ?></div>
  <a href="<?= url() ?>">← Volver</a>
</article>
php
<section style="text-align:center;padding:60px 0">
  <h1>404</h1>
  <p>La página que buscas no existe o cambió de dirección.</p>
  <a href="<?= url() ?>">Ir al inicio</a>
</section>

Generar slugs ​

php
<?php
/** "Taza de Cerámica Grande!" -> "taza-de-ceramica-grande" */
function slugify(string $texto): string
{
    $texto = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $texto); // quita acentos
    $texto = preg_replace('/[^a-zA-Z0-9]+/', '-', $texto);
    return strtolower(trim($texto, '-')) ?: 'sin-titulo';
}

// Slug único: si "taza" existe, usa "taza-2", "taza-3"...
function slug_unico(string $texto, string $tabla = 'productos'): string
{
    $base = slugify($texto);
    $slug = $base;
    $n = 2;
    $stmt = db()->prepare("SELECT 1 FROM $tabla WHERE slug = ?");
    while (true) {
        $stmt->execute([$slug]);
        if (!$stmt->fetch()) return $slug;
        $slug = $base . '-' . $n++;
    }
}

Error común

Los CSS e imágenes "desaparecen" al entrar en /productos/taza. Es porque usaste rutas relativas (assets/css/estilos.css) y el navegador las busca en /productos/assets/.... Usa rutas absolutas con la función url().

Nginx

Si tu servidor es Nginx en lugar de Apache, el equivalente del .htaccess es: location / { try_files $uri $uri/ /index.php?$query_string; }.

Academia Web — aprende, copia y construye.