Tema
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; }.