Skip to content

Blog/CMS en PHP + MySQL ​

Proyecto full-stack completo: un blog con panel de administración hecho con PHP 8 y MySQL, sin frameworks, listo para subir a cualquier hosting compartido (cPanel, Hostinger, SiteGround...). Incluye login seguro, CRUD de publicaciones con subida de imágenes, categorías, borradores, URLs amigables, paginación y buscador.

Es un proyecto ideal para tu portafolio y para clientes que quieren "una página con noticias que yo mismo pueda actualizar" sin depender de WordPress.

Lo que vas a construir ​

Parte pública

  • Portada con los últimos posts publicados, paginación de 6 en 6.
  • Filtro por categoría y buscador.
  • Página de cada post con URL amigable: /post/como-cotizar-un-sitio-web.

Panel de administración (/admin)

  • Login con password_hash, sesión segura, CSRF y freno de fuerza bruta.
  • Dashboard con conteos.
  • Listado de posts con estado (publicado / borrador).
  • Crear, editar y eliminar posts con imagen de portada (validada y redimensionada).
  • Gestión de categorías.

Requisitos ​

  • PHP 8.1+ con pdo_mysql, gd, fileinfo y mbstring (vienen en cualquier hosting).
  • MySQL 5.7+ o MariaDB 10.3+.
  • Apache con mod_rewrite (para las URLs amigables).
  • En local: XAMPP, Laragon o MAMP.

Estructura de archivos ​

txt
blog/
├── .htaccess                 ← URLs amigables y protección
├── config.php                ← credenciales y ajustes (NO subir a Git)
├── config.example.php
├── schema.sql                ← tablas y datos de ejemplo
├── crear-admin.php           ← crea el primer usuario (borrar después)
├── index.php                 ← portada, categorías y búsqueda
├── post.php                  ← detalle de un post
├── assets/
│   └── estilos.css
├── uploads/
│   └── .htaccess             ← impide ejecutar PHP aquí
├── includes/
│   ├── .htaccess             ← bloquea acceso directo
│   ├── bootstrap.php         ← carga todo lo necesario
│   ├── db.php
│   ├── helpers.php
│   ├── auth.php
│   ├── imagenes.php
│   ├── header.php
│   └── footer.php
└── admin/
    ├── _inicio.php           ← layout del panel (parte superior)
    ├── _fin.php              ← layout del panel (parte inferior)
    ├── login.php
    ├── logout.php
    ├── index.php             ← dashboard
    ├── posts.php             ← listado
    ├── post-form.php         ← crear / editar
    ├── post-eliminar.php
    └── categorias.php

Paso 1: base de datos ​

Crea una base de datos llamada blog (en phpMyAdmin: Bases de datos → Crear, cotejamiento utf8mb4_unicode_ci) e importa este archivo. También puedes descargarlo: schema.sql.

sql
SET NAMES utf8mb4;

CREATE TABLE usuarios (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL,
  email VARCHAR(150) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  rol ENUM('admin','editor') NOT NULL DEFAULT 'editor',
  creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE categorias (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(80) NOT NULL,
  slug VARCHAR(100) NOT NULL UNIQUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE posts (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  usuario_id INT UNSIGNED NOT NULL,
  categoria_id INT UNSIGNED NULL,
  titulo VARCHAR(200) NOT NULL,
  slug VARCHAR(220) NOT NULL UNIQUE,
  extracto VARCHAR(300) NULL,
  contenido MEDIUMTEXT NOT NULL,
  imagen VARCHAR(255) NULL,
  estado ENUM('borrador','publicado') NOT NULL DEFAULT 'borrador',
  publicado_en DATETIME NULL,
  creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  actualizado_en TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP,
  KEY idx_posts_estado_fecha (estado, publicado_en),
  FOREIGN KEY (usuario_id) REFERENCES usuarios(id),
  FOREIGN KEY (categoria_id) REFERENCES categorias(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE intentos_login (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  ip VARCHAR(45) NOT NULL,
  creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  KEY idx_ip_fecha (ip, creado_en)
) ENGINE=InnoDB;

INSERT INTO categorias (nombre, slug) VALUES
  ('Noticias', 'noticias'), ('Tutoriales', 'tutoriales'), ('Negocios', 'negocios');

Paso 2: configuración ​

En hosting compartido es más sencillo un config.php que un .env. Copia config.example.php como config.php y llena tus datos:

php
<?php
// ⚠️ No subas este archivo a Git. Sube config.example.php sin contraseñas.
return [
    'app' => [
        'nombre'   => 'Mi Blog',
        'url'      => 'http://localhost/blog',   // sin barra final. En producción: https://tudominio.com
        'debug'    => true,                       // false en producción
        'zona'     => 'America/Mexico_City',
        'por_pagina' => 6,
    ],
    'db' => [
        'host' => '127.0.0.1',
        'name' => 'blog',
        'user' => 'root',
        'pass' => '',
    ],
];

Paso 3: el núcleo (includes/) ​

php
<?php
declare(strict_types=1);

// Carga la configuración una sola vez
$GLOBALS['config'] = require __DIR__ . '/../config.php';

function config(string $clave, mixed $defecto = null): mixed
{
    $valor = $GLOBALS['config'];
    foreach (explode('.', $clave) as $parte) {
        if (!is_array($valor) || !array_key_exists($parte, $valor)) return $defecto;
        $valor = $valor[$parte];
    }
    return $valor;
}

date_default_timezone_set(config('app.zona', 'UTC'));

// Errores: visibles sólo en desarrollo
ini_set('display_errors', config('app.debug') ? '1' : '0');
error_reporting(E_ALL);

// Sesión con cookies seguras
session_set_cookie_params([
    'lifetime' => 0,
    'path'     => '/',
    'secure'   => !empty($_SERVER['HTTPS']),
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();

require __DIR__ . '/db.php';
require __DIR__ . '/helpers.php';
require __DIR__ . '/auth.php';
php
<?php
function db(): PDO
{
    static $pdo = null;
    if ($pdo === null) {
        $dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', config('db.host'), config('db.name'));
        try {
            $pdo = new PDO($dsn, config('db.user'), config('db.pass'), [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]);
        } catch (PDOException $e) {
            error_log($e->getMessage());
            http_response_code(500);
            exit(config('app.debug') ? 'Error de conexión: ' . $e->getMessage() : 'Error interno. Intenta más tarde.');
        }
    }
    return $pdo;
}
php
<?php
/** Escapa texto para HTML (usar SIEMPRE al imprimir datos) */
function e(?string $texto): string
{
    return htmlspecialchars($texto ?? '', ENT_QUOTES, 'UTF-8');
}

/** URL absoluta del sitio: url('post/mi-slug') */
function url(string $ruta = ''): string
{
    return rtrim(config('app.url'), '/') . '/' . ltrim($ruta, '/');
}

function redirect(string $ruta): never
{
    header('Location: ' . (str_starts_with($ruta, 'http') ? $ruta : url($ruta)));
    exit;
}

/** "Cómo cotizar un sitio" -> "como-cotizar-un-sitio" */
function slugify(string $texto): string
{
    $texto = mb_strtolower(trim($texto));
    $texto = strtr($texto, ['á'=>'a','é'=>'e','í'=>'i','ó'=>'o','ú'=>'u','ü'=>'u','ñ'=>'n']);
    $texto = preg_replace('/[^a-z0-9]+/', '-', $texto);
    return trim($texto, '-') ?: 'post';
}

/** Genera un slug que no exista en la tabla (ignora el registro $ignorarId al editar) */
function slug_unico(string $titulo, string $tabla, int $ignorarId = 0): string
{
    $base = slugify($titulo);
    $slug = $base;
    $n = 2;
    $stmt = db()->prepare("SELECT COUNT(*) FROM $tabla WHERE slug = ? AND id <> ?");
    while (true) {
        $stmt->execute([$slug, $ignorarId]);
        if ((int) $stmt->fetchColumn() === 0) return $slug;
        $slug = $base . '-' . $n++;
    }
}

/** CSRF */
function csrf_token(): string
{
    return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}

function csrf_field(): string
{
    return '<input type="hidden" name="csrf" value="' . csrf_token() . '">';
}

function csrf_check(): void
{
    if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
        http_response_code(419);
        exit('La sesión expiró. Regresa y recarga la página.');
    }
}

/** Mensajes flash */
function flash(string $tipo, ?string $mensaje = null): ?string
{
    if ($mensaje !== null) {
        $_SESSION['flash'][$tipo] = $mensaje;
        return null;
    }
    $m = $_SESSION['flash'][$tipo] ?? null;
    unset($_SESSION['flash'][$tipo]);
    return $m;
}

/** Fecha bonita en español: "18 de octubre de 2026" */
function fecha_es(?string $fecha): string
{
    if (!$fecha) return '';
    $meses = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre'];
    $t = strtotime($fecha);
    return date('j', $t) . ' de ' . $meses[(int) date('n', $t) - 1] . ' de ' . date('Y', $t);
}

/** Recorta texto sin cortar palabras */
function recortar(string $texto, int $max = 160): string
{
    $texto = trim(preg_replace('/\s+/', ' ', strip_tags($texto)));
    if (mb_strlen($texto) <= $max) return $texto;
    return rtrim(mb_substr($texto, 0, mb_strrpos(mb_substr($texto, 0, $max), ' ') ?: $max), ',.;') . '…';
}

/**
 * Convierte el texto del post en HTML seguro:
 * escapa todo y luego permite párrafos, saltos, **negritas**, _cursivas_ y enlaces [texto](https://...)
 */
function formatear_contenido(string $texto): string
{
    $html = e($texto);
    $html = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $html);
    $html = preg_replace('/(?<!\w)_(.+?)_(?!\w)/s', '<em>$1</em>', $html);
    $html = preg_replace('/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/', '<a href="$2" target="_blank" rel="noopener">$1</a>', $html);
    $parrafos = preg_split('/\R{2,}/', trim($html));
    return implode("\n", array_map(fn($p) => '<p>' . nl2br($p) . '</p>', $parrafos));
}
php
<?php
function usuario_actual(): ?array
{
    static $u = false;
    if ($u === false) {
        $u = null;
        if (!empty($_SESSION['usuario_id'])) {
            $stmt = db()->prepare('SELECT id, nombre, email, rol FROM usuarios WHERE id = ?');
            $stmt->execute([$_SESSION['usuario_id']]);
            $u = $stmt->fetch() ?: null;
        }
    }
    return $u;
}

/** Protege las páginas del panel */
function requiere_login(): array
{
    $u = usuario_actual();
    if (!$u) {
        flash('error', 'Inicia sesión para continuar.');
        redirect('admin/login.php');
    }
    return $u;
}

function requiere_admin(): array
{
    $u = requiere_login();
    if ($u['rol'] !== 'admin') {
        http_response_code(403);
        exit('Sólo los administradores pueden ver esta página.');
    }
    return $u;
}

/** Freno de fuerza bruta: máximo 5 intentos fallidos por IP cada 15 minutos */
function demasiados_intentos(string $ip): bool
{
    $stmt = db()->prepare('SELECT COUNT(*) FROM intentos_login WHERE ip = ? AND creado_en > (NOW() - INTERVAL 15 MINUTE)');
    $stmt->execute([$ip]);
    return (int) $stmt->fetchColumn() >= 5;
}

function registrar_intento(string $ip): void
{
    db()->prepare('INSERT INTO intentos_login (ip) VALUES (?)')->execute([$ip]);
    // Limpieza de registros viejos
    db()->exec('DELETE FROM intentos_login WHERE creado_en < (NOW() - INTERVAL 1 DAY)');
}
php
<?php
/**
 * Valida, redimensiona y guarda una imagen subida. Devuelve el nombre del archivo.
 * Lanza RuntimeException con un mensaje apto para el usuario.
 */
function guardar_imagen(array $archivo, int $maxAncho = 1400): string
{
    if (($archivo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        throw new RuntimeException('No se pudo subir la imagen (¿pesa más de lo permitido?).');
    }
    if ($archivo['size'] > 5 * 1024 * 1024) {
        throw new RuntimeException('La imagen no debe pesar más de 5 MB.');
    }

    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($archivo['tmp_name']);
    $abrir = [
        'image/jpeg' => 'imagecreatefromjpeg',
        'image/png'  => 'imagecreatefrompng',
        'image/webp' => 'imagecreatefromwebp',
    ][$mime] ?? null;
    if (!$abrir) {
        throw new RuntimeException('Formato no permitido. Usa JPG, PNG o WebP.');
    }

    $origen = @$abrir($archivo['tmp_name']);
    if (!$origen) {
        throw new RuntimeException('El archivo no es una imagen válida.');
    }

    $ancho = imagesx($origen);
    $alto = imagesy($origen);
    $escala = min(1, $maxAncho / $ancho);
    $nuevoAncho = (int) round($ancho * $escala);
    $nuevoAlto = (int) round($alto * $escala);

    $lienzo = imagecreatetruecolor($nuevoAncho, $nuevoAlto);
    // Fondo blanco por si el PNG tiene transparencia (guardamos en JPG)
    imagefill($lienzo, 0, 0, imagecolorallocate($lienzo, 255, 255, 255));
    imagecopyresampled($lienzo, $origen, 0, 0, 0, 0, $nuevoAncho, $nuevoAlto, $ancho, $alto);

    $nombre = date('Y/m/') . bin2hex(random_bytes(12)) . '.jpg';   // uploads/2026/10/abc123.jpg
    $ruta = __DIR__ . '/../uploads/' . $nombre;
    if (!is_dir(dirname($ruta)) && !mkdir(dirname($ruta), 0755, true)) {
        throw new RuntimeException('No se pudo crear la carpeta de imágenes. Revisa los permisos de uploads/.');
    }
    imagejpeg($lienzo, $ruta, 82);
    imagedestroy($origen);
    imagedestroy($lienzo);

    return $nombre;
}

function borrar_imagen(?string $nombre): void
{
    if (!$nombre) return;
    $ruta = realpath(__DIR__ . '/../uploads/' . $nombre);
    $base = realpath(__DIR__ . '/../uploads');
    // Sólo borra archivos que estén realmente dentro de uploads/
    if ($ruta && $base && str_starts_with($ruta, $base) && is_file($ruta)) {
        unlink($ruta);
    }
}
php
<?php
$categoriasMenu = db()->query('SELECT nombre, slug FROM categorias ORDER BY nombre')->fetchAll();
$tituloPagina = isset($titulo) ? $titulo . ' | ' . config('app.nombre') : config('app.nombre');
?>
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title><?= e($tituloPagina) ?></title>
  <meta name="description" content="<?= e($descripcion ?? 'Artículos, noticias y tutoriales.') ?>">
  <?php if (!empty($imagenOg)): ?><meta property="og:image" content="<?= e($imagenOg) ?>"><?php endif; ?>
  <link rel="stylesheet" href="<?= url('assets/estilos.css') ?>">
</head>
<body>
  <header class="sitio-header">
    <div class="contenedor">
      <a class="logo" href="<?= url() ?>"><?= e(config('app.nombre')) ?></a>
      <nav>
        <?php foreach ($categoriasMenu as $c): ?>
          <a href="<?= url('categoria/' . $c['slug']) ?>"><?= e($c['nombre']) ?></a>
        <?php endforeach; ?>
      </nav>
      <form class="buscar" action="<?= url() ?>" method="get">
        <input type="search" name="q" placeholder="Buscar…" value="<?= e($_GET['q'] ?? '') ?>" aria-label="Buscar">
      </form>
    </div>
  </header>
  <main class="contenedor">
php
  </main>
  <footer class="sitio-footer">
    <div class="contenedor">© <?= date('Y') ?> <?= e(config('app.nombre')) ?> · Hecho con PHP y MySQL</div>
  </footer>
</body>
</html>

Paso 4: parte pública ​

Portada con paginación, categorías y buscador ​

php
<?php
require __DIR__ . '/includes/bootstrap.php';

$porPagina = (int) config('app.por_pagina', 6);
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$q = trim($_GET['q'] ?? '');
$catSlug = $_GET['categoria'] ?? '';

$where = ["p.estado = 'publicado'", 'p.publicado_en <= NOW()'];
$params = [];
$categoria = null;

if ($catSlug !== '') {
    $stmt = db()->prepare('SELECT * FROM categorias WHERE slug = ?');
    $stmt->execute([$catSlug]);
    $categoria = $stmt->fetch();
    if (!$categoria) {
        http_response_code(404);
        exit('Categoría no encontrada');
    }
    $where[] = 'p.categoria_id = ?';
    $params[] = $categoria['id'];
}
if ($q !== '') {
    $where[] = '(p.titulo LIKE ? OR p.contenido LIKE ?)';
    $params[] = "%$q%";
    $params[] = "%$q%";
}
$sqlWhere = 'WHERE ' . implode(' AND ', $where);

$stmt = db()->prepare("SELECT COUNT(*) FROM posts p $sqlWhere");
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
$paginas = max(1, (int) ceil($total / $porPagina));
$pagina = min($pagina, $paginas);
$offset = ($pagina - 1) * $porPagina;

$stmt = db()->prepare(
    "SELECT p.titulo, p.slug, p.extracto, p.contenido, p.imagen, p.publicado_en,
            c.nombre AS categoria, c.slug AS categoria_slug, u.nombre AS autor
     FROM posts p
     LEFT JOIN categorias c ON c.id = p.categoria_id
     JOIN usuarios u ON u.id = p.usuario_id
     $sqlWhere
     ORDER BY p.publicado_en DESC
     LIMIT $porPagina OFFSET $offset"
);
$stmt->execute($params);
$posts = $stmt->fetchAll();

// Conserva filtros en los enlaces de paginación
function enlace_pagina(int $n): string
{
    $query = array_filter(['q' => $_GET['q'] ?? '', 'pagina' => $n > 1 ? $n : null]);
    $base = isset($_GET['categoria']) ? url('categoria/' . $_GET['categoria']) : url();
    return $base . ($query ? '?' . http_build_query($query) : '');
}

$titulo = $categoria ? $categoria['nombre'] : ($q !== '' ? "Resultados para «{$q}»" : null);
require __DIR__ . '/includes/header.php';
?>

<?php if ($titulo): ?>
  <h1 class="titulo-listado"><?= e($titulo) ?> <small>(<?= $total ?>)</small></h1>
<?php endif; ?>

<?php if (!$posts): ?>
  <p class="vacio">No hay publicaciones por aquí todavía.</p>
<?php else: ?>
  <div class="grid-posts">
    <?php foreach ($posts as $p): ?>
      <article class="tarjeta-post">
        <a href="<?= url('post/' . $p['slug']) ?>" class="imagen">
          <?php if ($p['imagen']): ?>
            <img src="<?= url('uploads/' . $p['imagen']) ?>" alt="<?= e($p['titulo']) ?>" loading="lazy">
          <?php else: ?>
            <span class="sin-imagen"><?= e(mb_substr($p['titulo'], 0, 1)) ?></span>
          <?php endif; ?>
        </a>
        <div class="cuerpo">
          <?php if ($p['categoria']): ?>
            <a class="etiqueta" href="<?= url('categoria/' . $p['categoria_slug']) ?>"><?= e($p['categoria']) ?></a>
          <?php endif; ?>
          <h2><a href="<?= url('post/' . $p['slug']) ?>"><?= e($p['titulo']) ?></a></h2>
          <p><?= e($p['extracto'] ?: recortar($p['contenido'])) ?></p>
          <small><?= e($p['autor']) ?> · <?= fecha_es($p['publicado_en']) ?></small>
        </div>
      </article>
    <?php endforeach; ?>
  </div>

  <?php if ($paginas > 1): ?>
    <nav class="paginacion" aria-label="Paginación">
      <?php for ($i = 1; $i <= $paginas; $i++): ?>
        <a href="<?= e(enlace_pagina($i)) ?>" class="<?= $i === $pagina ? 'actual' : '' ?>"><?= $i ?></a>
      <?php endfor; ?>
    </nav>
  <?php endif; ?>
<?php endif; ?>

<?php require __DIR__ . '/includes/footer.php'; ?>

Detalle del post ​

php
<?php
require __DIR__ . '/includes/bootstrap.php';

$slug = $_GET['slug'] ?? '';
$stmt = db()->prepare(
    "SELECT p.*, c.nombre AS categoria, c.slug AS categoria_slug, u.nombre AS autor
     FROM posts p
     LEFT JOIN categorias c ON c.id = p.categoria_id
     JOIN usuarios u ON u.id = p.usuario_id
     WHERE p.slug = ? AND p.estado = 'publicado' AND p.publicado_en <= NOW()"
);
$stmt->execute([$slug]);
$post = $stmt->fetch();

// Permite a un usuario logueado previsualizar borradores
if (!$post && usuario_actual()) {
    $stmt = db()->prepare('SELECT p.*, NULL AS categoria, NULL AS categoria_slug, u.nombre AS autor FROM posts p JOIN usuarios u ON u.id = p.usuario_id WHERE p.slug = ?');
    $stmt->execute([$slug]);
    $post = $stmt->fetch();
}

if (!$post) {
    http_response_code(404);
    $titulo = 'Página no encontrada';
    require __DIR__ . '/includes/header.php';
    echo '<div class="vacio"><h1>404</h1><p>El artículo que buscas no existe o fue retirado.</p><a href="' . url() . '">Volver al inicio</a></div>';
    require __DIR__ . '/includes/footer.php';
    exit;
}

// Variables para el header (SEO)
$titulo = $post['titulo'];
$descripcion = $post['extracto'] ?: recortar($post['contenido']);
$imagenOg = $post['imagen'] ? url('uploads/' . $post['imagen']) : null;

// Relacionados de la misma categoría
$relacionados = [];
if ($post['categoria_id']) {
    $stmt = db()->prepare("SELECT titulo, slug FROM posts WHERE categoria_id = ? AND id <> ? AND estado = 'publicado' ORDER BY publicado_en DESC LIMIT 3");
    $stmt->execute([$post['categoria_id'], $post['id']]);
    $relacionados = $stmt->fetchAll();
}

require __DIR__ . '/includes/header.php';
?>
<article class="post">
  <?php if ($post['estado'] !== 'publicado'): ?>
    <p class="aviso-borrador">Vista previa: este post es un borrador y no es visible al público.</p>
  <?php endif; ?>

  <?php if ($post['categoria']): ?>
    <a class="etiqueta" href="<?= url('categoria/' . $post['categoria_slug']) ?>"><?= e($post['categoria']) ?></a>
  <?php endif; ?>
  <h1><?= e($post['titulo']) ?></h1>
  <p class="meta">Por <?= e($post['autor']) ?> · <?= fecha_es($post['publicado_en'] ?? $post['creado_en']) ?></p>

  <?php if ($post['imagen']): ?>
    <img class="portada" src="<?= url('uploads/' . $post['imagen']) ?>" alt="<?= e($post['titulo']) ?>">
  <?php endif; ?>

  <div class="contenido-post"><?= formatear_contenido($post['contenido']) ?></div>

  <?php if ($relacionados): ?>
    <aside class="relacionados">
      <h3>También te puede interesar</h3>
      <ul>
        <?php foreach ($relacionados as $r): ?>
          <li><a href="<?= url('post/' . $r['slug']) ?>"><?= e($r['titulo']) ?></a></li>
        <?php endforeach; ?>
      </ul>
    </aside>
  <?php endif; ?>
</article>
<?php require __DIR__ . '/includes/footer.php'; ?>

Paso 5: crear el primer administrador ​

Sube este script, ábrelo una vez en el navegador (tusitio.com/crear-admin.php) y bórralo inmediatamente después.

php
<?php
require __DIR__ . '/includes/bootstrap.php';

// Si ya existe un usuario, el script se desactiva solo
if ((int) db()->query('SELECT COUNT(*) FROM usuarios')->fetchColumn() > 0) {
    exit('Ya existe al menos un usuario. Borra este archivo del servidor.');
}

$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    csrf_check();
    $nombre = trim($_POST['nombre'] ?? '');
    $email = strtolower(trim($_POST['email'] ?? ''));
    $pass = $_POST['password'] ?? '';

    if ($nombre === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($pass) < 10) {
        $error = 'Completa todos los campos. La contraseña debe tener al menos 10 caracteres.';
    } else {
        db()->prepare("INSERT INTO usuarios (nombre, email, password_hash, rol) VALUES (?, ?, ?, 'admin')")
            ->execute([$nombre, $email, password_hash($pass, PASSWORD_DEFAULT)]);
        exit('✅ Administrador creado. <strong>Borra crear-admin.php ahora</strong> y entra en <a href="admin/login.php">admin/login.php</a>.');
    }
}
?>
<!DOCTYPE html>
<html lang="es"><head><meta charset="UTF-8"><title>Crear administrador</title></head>
<body style="font-family:system-ui;max-width:420px;margin:60px auto">
  <h1>Crear administrador</h1>
  <?php if ($error): ?><p style="color:#b91c1c"><?= e($error) ?></p><?php endif; ?>
  <form method="post">
    <?= csrf_field() ?>
    <p><input name="nombre" placeholder="Nombre" required style="width:100%;padding:8px"></p>
    <p><input name="email" type="email" placeholder="Correo" required style="width:100%;padding:8px"></p>
    <p><input name="password" type="password" placeholder="Contraseña (mín. 10)" required style="width:100%;padding:8px"></p>
    <button>Crear</button>
  </form>
</body></html>

Paso 6: panel de administración ​

Layout del panel ​

php
<?php
// Requiere que la página haya definido $u (usuario) y opcionalmente $titulo
$actual = basename($_SERVER['SCRIPT_NAME']);
$ok = flash('ok');
$error = flash('error');
?>
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="robots" content="noindex">
  <title><?= e($titulo ?? 'Panel') ?> | Admin</title>
  <link rel="stylesheet" href="<?= url('assets/estilos.css') ?>">
</head>
<body class="admin">
  <aside class="admin-menu">
    <a class="logo" href="<?= url('admin/') ?>">⚙️ Panel</a>
    <a href="<?= url('admin/') ?>" class="<?= $actual === 'index.php' ? 'activo' : '' ?>">Inicio</a>
    <a href="<?= url('admin/posts.php') ?>" class="<?= str_starts_with($actual, 'post') ? 'activo' : '' ?>">Publicaciones</a>
    <a href="<?= url('admin/categorias.php') ?>" class="<?= $actual === 'categorias.php' ? 'activo' : '' ?>">Categorías</a>
    <a href="<?= url() ?>" target="_blank">Ver sitio ↗</a>
    <form method="post" action="<?= url('admin/logout.php') ?>">
      <?= csrf_field() ?>
      <button class="enlace-boton">Cerrar sesión (<?= e($u['nombre']) ?>)</button>
    </form>
  </aside>
  <main class="admin-contenido">
    <?php if ($ok): ?><div class="alerta ok"><?= e($ok) ?></div><?php endif; ?>
    <?php if ($error): ?><div class="alerta error"><?= e($error) ?></div><?php endif; ?>
php
  </main>
</body>
</html>

Login y logout ​

php
<?php
require __DIR__ . '/../includes/bootstrap.php';

if (usuario_actual()) redirect('admin/');

$error = flash('error');
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    csrf_check();

    if (demasiados_intentos($ip)) {
        $error = 'Demasiados intentos fallidos. Espera 15 minutos.';
    } else {
        $stmt = db()->prepare('SELECT id, password_hash FROM usuarios WHERE email = ?');
        $stmt->execute([strtolower(trim($_POST['email'] ?? ''))]);
        $usuario = $stmt->fetch();

        if ($usuario && password_verify($_POST['password'] ?? '', $usuario['password_hash'])) {
            session_regenerate_id(true);          // evita fijación de sesión
            $_SESSION['usuario_id'] = $usuario['id'];
            redirect('admin/');
        }
        registrar_intento($ip);
        $error = 'Correo o contraseña incorrectos.';
    }
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="robots" content="noindex">
  <title>Iniciar sesión | Admin</title>
  <link rel="stylesheet" href="<?= url('assets/estilos.css') ?>">
</head>
<body class="login">
  <form method="post" class="caja-login">
    <h1>Panel de <?= e(config('app.nombre')) ?></h1>
    <?php if ($error): ?><div class="alerta error"><?= e($error) ?></div><?php endif; ?>
    <?= csrf_field() ?>
    <label>Correo <input type="email" name="email" required autofocus></label>
    <label>Contraseña <input type="password" name="password" required></label>
    <button class="btn">Entrar</button>
  </form>
</body>
</html>
php
<?php
require __DIR__ . '/../includes/bootstrap.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}
csrf_check();

$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
session_destroy();

header('Location: ' . url('admin/login.php'));

Dashboard ​

php
<?php
require __DIR__ . '/../includes/bootstrap.php';
$u = requiere_login();
$titulo = 'Inicio';

$conteos = db()->query(
    "SELECT
       SUM(estado = 'publicado') AS publicados,
       SUM(estado = 'borrador')  AS borradores,
       COUNT(*)                  AS total
     FROM posts"
)->fetch();
$ultimos = db()->query('SELECT id, titulo, estado, creado_en FROM posts ORDER BY creado_en DESC LIMIT 5')->fetchAll();

require __DIR__ . '/_inicio.php';
?>
<h1>Hola, <?= e($u['nombre']) ?> 👋</h1>
<div class="kpis">
  <div class="kpi"><strong><?= (int) $conteos['publicados'] ?></strong>Publicados</div>
  <div class="kpi"><strong><?= (int) $conteos['borradores'] ?></strong>Borradores</div>
  <div class="kpi"><strong><?= (int) $conteos['total'] ?></strong>Total</div>
</div>
<p><a class="btn" href="post-form.php">+ Nueva publicación</a></p>

<h2>Últimas publicaciones</h2>
<table class="tabla">
  <tr><th>Título</th><th>Estado</th><th>Creado</th><th></th></tr>
  <?php foreach ($ultimos as $p): ?>
    <tr>
      <td><?= e($p['titulo']) ?></td>
      <td><span class="estado <?= $p['estado'] ?>"><?= ucfirst($p['estado']) ?></span></td>
      <td><?= fecha_es($p['creado_en']) ?></td>
      <td><a href="post-form.php?id=<?= $p['id'] ?>">Editar</a></td>
    </tr>
  <?php endforeach; ?>
</table>
<?php require __DIR__ . '/_fin.php'; ?>

Listado de publicaciones ​

php
<?php
require __DIR__ . '/../includes/bootstrap.php';
$u = requiere_login();
$titulo = 'Publicaciones';

$estado = in_array($_GET['estado'] ?? '', ['publicado', 'borrador'], true) ? $_GET['estado'] : '';
$sql = 'SELECT p.id, p.titulo, p.slug, p.estado, p.imagen, p.publicado_en, p.creado_en, c.nombre AS categoria
        FROM posts p LEFT JOIN categorias c ON c.id = p.categoria_id';
$params = [];
if ($estado) {
    $sql .= ' WHERE p.estado = ?';
    $params[] = $estado;
}
$sql .= ' ORDER BY p.creado_en DESC';
$stmt = db()->prepare($sql);
$stmt->execute($params);
$posts = $stmt->fetchAll();

require __DIR__ . '/_inicio.php';
?>
<div class="cabecera-admin">
  <h1>Publicaciones</h1>
  <a class="btn" href="post-form.php">+ Nueva</a>
</div>
<p class="filtros">
  <a href="posts.php" class="<?= !$estado ? 'activo' : '' ?>">Todas</a> ·
  <a href="?estado=publicado" class="<?= $estado === 'publicado' ? 'activo' : '' ?>">Publicadas</a> ·
  <a href="?estado=borrador" class="<?= $estado === 'borrador' ? 'activo' : '' ?>">Borradores</a>
</p>

<table class="tabla">
  <tr><th></th><th>Título</th><th>Categoría</th><th>Estado</th><th>Fecha</th><th>Acciones</th></tr>
  <?php foreach ($posts as $p): ?>
    <tr>
      <td><?php if ($p['imagen']): ?><img class="miniatura" src="<?= url('uploads/' . $p['imagen']) ?>" alt=""><?php endif; ?></td>
      <td><strong><?= e($p['titulo']) ?></strong></td>
      <td><?= e($p['categoria'] ?? '—') ?></td>
      <td><span class="estado <?= $p['estado'] ?>"><?= ucfirst($p['estado']) ?></span></td>
      <td><?= fecha_es($p['publicado_en'] ?? $p['creado_en']) ?></td>
      <td class="acciones">
        <a href="<?= url('post/' . $p['slug']) ?>" target="_blank">Ver</a>
        <a href="post-form.php?id=<?= $p['id'] ?>">Editar</a>
        <form method="post" action="post-eliminar.php" onsubmit="return confirm('¿Eliminar esta publicación? No se puede deshacer.')">
          <?= csrf_field() ?>
          <input type="hidden" name="id" value="<?= $p['id'] ?>">
          <button class="enlace-boton peligro">Eliminar</button>
        </form>
      </td>
    </tr>
  <?php endforeach; ?>
  <?php if (!$posts): ?><tr><td colspan="6">No hay publicaciones.</td></tr><?php endif; ?>
</table>
<?php require __DIR__ . '/_fin.php'; ?>

Crear y editar (con subida de imagen) ​

php
<?php
require __DIR__ . '/../includes/bootstrap.php';
require __DIR__ . '/../includes/imagenes.php';
$u = requiere_login();

$id = (int) ($_GET['id'] ?? 0);
$post = ['titulo' => '', 'extracto' => '', 'contenido' => '', 'categoria_id' => null, 'estado' => 'borrador', 'imagen' => null, 'publicado_en' => null];

if ($id) {
    $stmt = db()->prepare('SELECT * FROM posts WHERE id = ?');
    $stmt->execute([$id]);
    $post = $stmt->fetch() ?: exit('Publicación no encontrada');
}

$categorias = db()->query('SELECT id, nombre FROM categorias ORDER BY nombre')->fetchAll();
$errores = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    csrf_check();

    $datos = [
        'titulo'       => trim($_POST['titulo'] ?? ''),
        'extracto'     => trim($_POST['extracto'] ?? '') ?: null,
        'contenido'    => trim($_POST['contenido'] ?? ''),
        'categoria_id' => (int) ($_POST['categoria_id'] ?? 0) ?: null,
        'estado'       => ($_POST['estado'] ?? '') === 'publicado' ? 'publicado' : 'borrador',
    ];

    if (mb_strlen($datos['titulo']) < 5 || mb_strlen($datos['titulo']) > 200) $errores['titulo'] = 'El título debe tener entre 5 y 200 caracteres.';
    if (mb_strlen($datos['contenido']) < 20) $errores['contenido'] = 'Escribe al menos 20 caracteres de contenido.';
    if ($datos['extracto'] && mb_strlen($datos['extracto']) > 300) $errores['extracto'] = 'El extracto debe tener máximo 300 caracteres.';

    // Imagen nueva (opcional)
    $imagenNueva = null;
    if (!$errores && !empty($_FILES['imagen']['name'])) {
        try {
            $imagenNueva = guardar_imagen($_FILES['imagen']);
        } catch (RuntimeException $ex) {
            $errores['imagen'] = $ex->getMessage();
        }
    }

    if (!$errores) {
        $imagenFinal = $post['imagen'];
        if ($imagenNueva) {
            borrar_imagen($post['imagen']);          // reemplaza la anterior
            $imagenFinal = $imagenNueva;
        } elseif (!empty($_POST['quitar_imagen'])) {
            borrar_imagen($post['imagen']);
            $imagenFinal = null;
        }

        // Fecha de publicación: se fija la primera vez que se publica
        $publicadoEn = $post['publicado_en'];
        if ($datos['estado'] === 'publicado' && !$publicadoEn) {
            $publicadoEn = date('Y-m-d H:i:s');
        }

        if ($id) {
            $slug = slug_unico($datos['titulo'], 'posts', $id);
            db()->prepare('UPDATE posts SET titulo = ?, slug = ?, extracto = ?, contenido = ?, categoria_id = ?, estado = ?, imagen = ?, publicado_en = ? WHERE id = ?')
                ->execute([$datos['titulo'], $slug, $datos['extracto'], $datos['contenido'], $datos['categoria_id'], $datos['estado'], $imagenFinal, $publicadoEn, $id]);
            flash('ok', 'Publicación actualizada.');
        } else {
            $slug = slug_unico($datos['titulo'], 'posts');
            db()->prepare('INSERT INTO posts (usuario_id, titulo, slug, extracto, contenido, categoria_id, estado, imagen, publicado_en) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
                ->execute([$u['id'], $datos['titulo'], $slug, $datos['extracto'], $datos['contenido'], $datos['categoria_id'], $datos['estado'], $imagenFinal, $publicadoEn]);
            flash('ok', 'Publicación creada.');
        }
        redirect('admin/posts.php');
    }

    // Si hubo errores, borra la imagen recién subida y conserva lo escrito
    if ($imagenNueva) borrar_imagen($imagenNueva);
    $post = array_merge($post, $datos);
}

$titulo = $id ? 'Editar publicación' : 'Nueva publicación';
require __DIR__ . '/_inicio.php';
?>
<h1><?= e($titulo) ?></h1>

<form method="post" enctype="multipart/form-data" class="formulario">
  <?= csrf_field() ?>

  <label>Título
    <input name="titulo" value="<?= e($post['titulo']) ?>" required maxlength="200">
    <small class="error"><?= e($errores['titulo'] ?? '') ?></small>
  </label>

  <label>Extracto (resumen para la portada y Google)
    <textarea name="extracto" rows="2" maxlength="300"><?= e($post['extracto']) ?></textarea>
    <small class="error"><?= e($errores['extracto'] ?? '') ?></small>
  </label>

  <label>Contenido
    <textarea name="contenido" rows="14" required><?= e($post['contenido']) ?></textarea>
    <small>Separa los párrafos con una línea en blanco. Formato: **negrita**, _cursiva_, [enlace](https://...).</small>
    <small class="error"><?= e($errores['contenido'] ?? '') ?></small>
  </label>

  <div class="dos-columnas">
    <label>Categoría
      <select name="categoria_id">
        <option value="">Sin categoría</option>
        <?php foreach ($categorias as $c): ?>
          <option value="<?= $c['id'] ?>" <?= (int) $post['categoria_id'] === (int) $c['id'] ? 'selected' : '' ?>><?= e($c['nombre']) ?></option>
        <?php endforeach; ?>
      </select>
    </label>
    <label>Estado
      <select name="estado">
        <option value="borrador" <?= $post['estado'] === 'borrador' ? 'selected' : '' ?>>Borrador</option>
        <option value="publicado" <?= $post['estado'] === 'publicado' ? 'selected' : '' ?>>Publicado</option>
      </select>
    </label>
  </div>

  <label>Imagen de portada (JPG, PNG o WebP, máx. 5 MB)
    <input type="file" name="imagen" accept="image/jpeg,image/png,image/webp">
    <small class="error"><?= e($errores['imagen'] ?? '') ?></small>
  </label>
  <?php if ($post['imagen']): ?>
    <div class="imagen-actual">
      <img src="<?= url('uploads/' . $post['imagen']) ?>" alt="Imagen actual">
      <label class="check"><input type="checkbox" name="quitar_imagen" value="1"> Quitar imagen</label>
    </div>
  <?php endif; ?>

  <div class="botones">
    <button class="btn">Guardar</button>
    <a href="posts.php">Cancelar</a>
  </div>
</form>
<?php require __DIR__ . '/_fin.php'; ?>

Eliminar ​

php
<?php
require __DIR__ . '/../includes/bootstrap.php';
require __DIR__ . '/../includes/imagenes.php';
requiere_login();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit('Método no permitido');
}
csrf_check();

$id = (int) ($_POST['id'] ?? 0);
$stmt = db()->prepare('SELECT imagen FROM posts WHERE id = ?');
$stmt->execute([$id]);
$post = $stmt->fetch();

if ($post) {
    db()->prepare('DELETE FROM posts WHERE id = ?')->execute([$id]);
    borrar_imagen($post['imagen']);
    flash('ok', 'Publicación eliminada.');
} else {
    flash('error', 'La publicación no existe.');
}
redirect('admin/posts.php');

Categorías (solo administradores) ​

php
<?php
require __DIR__ . '/../includes/bootstrap.php';
$u = requiere_admin();
$titulo = 'Categorías';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    csrf_check();
    $accion = $_POST['accion'] ?? '';

    if ($accion === 'crear') {
        $nombre = trim($_POST['nombre'] ?? '');
        if (mb_strlen($nombre) < 2) {
            flash('error', 'Escribe un nombre de al menos 2 caracteres.');
        } else {
            db()->prepare('INSERT INTO categorias (nombre, slug) VALUES (?, ?)')
                ->execute([$nombre, slug_unico($nombre, 'categorias')]);
            flash('ok', 'Categoría creada.');
        }
    }

    if ($accion === 'renombrar') {
        $id = (int) $_POST['id'];
        $nombre = trim($_POST['nombre'] ?? '');
        if (mb_strlen($nombre) >= 2) {
            db()->prepare('UPDATE categorias SET nombre = ?, slug = ? WHERE id = ?')
                ->execute([$nombre, slug_unico($nombre, 'categorias', $id), $id]);
            flash('ok', 'Categoría actualizada.');
        }
    }

    if ($accion === 'eliminar') {
        // Los posts quedan "sin categoría" gracias a ON DELETE SET NULL
        db()->prepare('DELETE FROM categorias WHERE id = ?')->execute([(int) $_POST['id']]);
        flash('ok', 'Categoría eliminada.');
    }
    redirect('admin/categorias.php');
}

$categorias = db()->query(
    'SELECT c.id, c.nombre, c.slug, COUNT(p.id) AS total
     FROM categorias c LEFT JOIN posts p ON p.categoria_id = c.id
     GROUP BY c.id ORDER BY c.nombre'
)->fetchAll();

require __DIR__ . '/_inicio.php';
?>
<h1>Categorías</h1>

<form method="post" class="en-linea">
  <?= csrf_field() ?>
  <input type="hidden" name="accion" value="crear">
  <input name="nombre" placeholder="Nueva categoría" required>
  <button class="btn">Agregar</button>
</form>

<table class="tabla">
  <tr><th>Nombre</th><th>Slug</th><th>Posts</th><th>Acciones</th></tr>
  <?php foreach ($categorias as $c): ?>
    <tr>
      <td>
        <form method="post" class="en-linea">
          <?= csrf_field() ?>
          <input type="hidden" name="accion" value="renombrar">
          <input type="hidden" name="id" value="<?= $c['id'] ?>">
          <input name="nombre" value="<?= e($c['nombre']) ?>" required>
          <button class="enlace-boton">Guardar</button>
        </form>
      </td>
      <td><code><?= e($c['slug']) ?></code></td>
      <td><?= (int) $c['total'] ?></td>
      <td>
        <form method="post" onsubmit="return confirm('¿Eliminar la categoría? Sus posts quedarán sin categoría.')">
          <?= csrf_field() ?>
          <input type="hidden" name="accion" value="eliminar">
          <input type="hidden" name="id" value="<?= $c['id'] ?>">
          <button class="enlace-boton peligro">Eliminar</button>
        </form>
      </td>
    </tr>
  <?php endforeach; ?>
</table>
<?php require __DIR__ . '/_fin.php'; ?>

Paso 7: .htaccess y protección de carpetas ​

apache
Options -Indexes
RewriteEngine On

# Si el blog está en una subcarpeta (localhost/blog), descomenta y ajusta:
# RewriteBase /blog/

# Forzar HTTPS en producción (descomenta cuando tengas SSL)
# RewriteCond %{HTTPS} off
# RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

# Bloquear archivos sensibles
RewriteRule ^(config\.php|config\.example\.php|schema\.sql)$ - [F,L]
RewriteRule ^includes/ - [F,L]

# URLs amigables
RewriteRule ^post/([a-z0-9-]+)/?$ post.php?slug=$1 [L,QSA]
RewriteRule ^categoria/([a-z0-9-]+)/?$ index.php?categoria=$1 [L,QSA]
apache
# Nadie puede abrir estos archivos desde el navegador
Require all denied
apache
# Las imágenes se sirven, pero jamás se ejecuta PHP aquí
php_flag engine off
<FilesMatch "\.(php|phtml|phar|php\d)$">
    Require all denied
</FilesMatch>
Options -Indexes

Si php_flag da error 500

Algunos hostings (PHP-FPM) no aceptan php_flag en .htaccess. Borra esa línea: el bloque FilesMatch sigue impidiendo ejecutar PHP.

Paso 8: estilos ​

Un CSS sencillo y responsive para la parte pública y el panel. Personalízalo con los colores del cliente cambiando las variables.

css
:root {
  --primario: #4f46e5;
  --texto: #1f2937;
  --suave: #6b7280;
  --fondo: #f9fafb;
  --borde: #e5e7eb;
  --radio: 12px;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; color: var(--texto); background: var(--fondo); line-height: 1.6; }
a { color: var(--primario); }
img { max-width: 100%; display: block; }
.contenedor { width: min(1100px, 100% - 32px); margin-inline: auto; }

/* Público */
.sitio-header { background: #fff; border-bottom: 1px solid var(--borde); margin-bottom: 32px; }
.sitio-header .contenedor { display: flex; align-items: center; gap: 20px; padding: 16px 0; flex-wrap: wrap; }
.logo { font-weight: 800; font-size: 22px; color: var(--texto); text-decoration: none; }
.sitio-header nav { display: flex; gap: 16px; flex: 1; flex-wrap: wrap; }
.sitio-header nav a { color: var(--suave); text-decoration: none; font-weight: 500; }
.buscar input { padding: 8px 14px; border: 1px solid var(--borde); border-radius: 999px; }
.titulo-listado small { color: var(--suave); font-weight: 400; font-size: .6em; }
.grid-posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 24px; }
.tarjeta-post { background: #fff; border: 1px solid var(--borde); border-radius: var(--radio); overflow: hidden; display: flex; flex-direction: column; }
.tarjeta-post .imagen { aspect-ratio: 16/9; background: linear-gradient(135deg, #c7d2fe, #fbcfe8); display: grid; place-items: center; overflow: hidden; }
.tarjeta-post .imagen img { width: 100%; height: 100%; object-fit: cover; }
.sin-imagen { font-size: 48px; font-weight: 800; color: #fff; }
.tarjeta-post .cuerpo { padding: 18px; }
.tarjeta-post h2 { font-size: 19px; margin: 8px 0; line-height: 1.3; }
.tarjeta-post h2 a { color: var(--texto); text-decoration: none; }
.tarjeta-post p { color: var(--suave); margin: 0 0 10px; }
.tarjeta-post small { color: var(--suave); }
.etiqueta { display: inline-block; font-size: 12px; font-weight: 700; background: #eef2ff; padding: 3px 10px; border-radius: 999px; text-decoration: none; }
.paginacion { display: flex; gap: 6px; justify-content: center; margin: 36px 0; }
.paginacion a { padding: 8px 14px; border: 1px solid var(--borde); border-radius: 8px; background: #fff; text-decoration: none; }
.paginacion a.actual { background: var(--primario); color: #fff; border-color: var(--primario); }
.post { max-width: 740px; margin: 0 auto; background: #fff; padding: 40px; border-radius: var(--radio); border: 1px solid var(--borde); }
.post h1 { font-size: clamp(28px, 4vw, 40px); line-height: 1.2; margin: 12px 0; }
.post .meta { color: var(--suave); }
.post .portada { border-radius: var(--radio); margin: 24px 0; }
.contenido-post { font-size: 18px; }
.relacionados { margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--borde); }
.aviso-borrador { background: #fef3c7; padding: 10px 14px; border-radius: 8px; }
.vacio { text-align: center; color: var(--suave); padding: 60px 0; }
.sitio-footer { margin-top: 60px; padding: 30px 0; border-top: 1px solid var(--borde); color: var(--suave); font-size: 14px; }

/* Botones y alertas */
.btn { display: inline-block; background: var(--primario); color: #fff; border: 0; padding: 10px 18px; border-radius: 8px; font-weight: 600; text-decoration: none; cursor: pointer; font-size: 15px; }
.enlace-boton { background: none; border: 0; color: var(--primario); cursor: pointer; padding: 0; font: inherit; }
.peligro { color: #dc2626; }
.alerta { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; }
.alerta.ok { background: #dcfce7; color: #166534; }
.alerta.error { background: #fee2e2; color: #991b1b; }

/* Login */
body.login { display: grid; place-items: center; min-height: 100vh; }
.caja-login { background: #fff; padding: 32px; border-radius: var(--radio); width: min(380px, 100% - 32px); border: 1px solid var(--borde); display: grid; gap: 14px; }
.caja-login h1 { font-size: 22px; margin: 0; }
.caja-login label { display: grid; gap: 6px; font-weight: 600; font-size: 14px; }
.caja-login input { padding: 10px 12px; border: 1px solid var(--borde); border-radius: 8px; font: inherit; }

/* Panel */
body.admin { display: grid; grid-template-columns: 230px 1fr; min-height: 100vh; }
.admin-menu { background: #111827; padding: 20px 14px; display: flex; flex-direction: column; gap: 4px; }
.admin-menu .logo { color: #fff; margin-bottom: 18px; padding: 0 10px; }
.admin-menu a, .admin-menu .enlace-boton { color: #9ca3af; text-decoration: none; padding: 9px 10px; border-radius: 8px; text-align: left; }
.admin-menu a:hover, .admin-menu a.activo { background: #1f2937; color: #fff; }
.admin-menu form { margin-top: auto; }
.admin-contenido { padding: 30px; min-width: 0; }
.cabecera-admin { display: flex; justify-content: space-between; align-items: center; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin: 20px 0; }
.kpi { background: #fff; border: 1px solid var(--borde); border-radius: var(--radio); padding: 18px; color: var(--suave); }
.kpi strong { display: block; font-size: 30px; color: var(--texto); }
.tabla { width: 100%; border-collapse: collapse; background: #fff; border-radius: var(--radio); overflow: hidden; border: 1px solid var(--borde); }
.tabla th, .tabla td { padding: 12px; border-bottom: 1px solid var(--borde); text-align: left; vertical-align: middle; }
.tabla th { background: #f3f4f6; font-size: 13px; color: var(--suave); }
.miniatura { width: 56px; height: 40px; object-fit: cover; border-radius: 6px; }
.acciones { display: flex; gap: 12px; align-items: center; }
.estado { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 999px; }
.estado.publicado { background: #dcfce7; color: #166534; }
.estado.borrador { background: #fef3c7; color: #92400e; }
.filtros a { text-decoration: none; color: var(--suave); }
.filtros a.activo { color: var(--primario); font-weight: 700; }
.formulario { display: grid; gap: 18px; max-width: 820px; background: #fff; padding: 24px; border-radius: var(--radio); border: 1px solid var(--borde); }
.formulario label { display: grid; gap: 6px; font-weight: 600; font-size: 14px; }
.formulario input, .formulario textarea, .formulario select { padding: 10px 12px; border: 1px solid var(--borde); border-radius: 8px; font: inherit; font-weight: 400; }
.formulario small { color: var(--suave); font-weight: 400; }
.formulario .error { color: #dc2626; }
.dos-columnas { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.imagen-actual img { max-width: 260px; border-radius: 8px; margin-bottom: 8px; }
.check { display: flex !important; align-items: center; gap: 8px; font-weight: 400 !important; }
.botones { display: flex; gap: 16px; align-items: center; }
.en-linea { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
.en-linea input { padding: 8px 10px; border: 1px solid var(--borde); border-radius: 8px; }

@media (max-width: 760px) {
  body.admin { grid-template-columns: 1fr; }
  .admin-menu { flex-direction: row; flex-wrap: wrap; }
  .admin-menu form { margin-top: 0; }
  .admin-contenido { padding: 16px; overflow-x: auto; }
  .post { padding: 22px; }
  .dos-columnas { grid-template-columns: 1fr; }
}

Paso 9: probar en local ​

  1. Copia la carpeta blog/ dentro de htdocs (XAMPP) o www (Laragon).
  2. Crea la base de datos e importa schema.sql.
  3. Ajusta config.php (url = http://localhost/blog) y descomenta RewriteBase /blog/ en .htaccess.
  4. Abre http://localhost/blog/crear-admin.php, crea tu usuario y borra el archivo.
  5. Entra a http://localhost/blog/admin/login.php y publica tu primer post.

Paso 10: subir a un hosting compartido ​

  1. Base de datos: en cPanel → Bases de datos MySQL, crea la base, un usuario con contraseña fuerte y asígnale todos los privilegios sobre esa base. Importa schema.sql desde phpMyAdmin → Importar.
  2. Archivos: sube todo el contenido de blog/ a public_html/ con el Administrador de archivos (sube un .zip y descomprímelo) o por FTP con FileZilla.
  3. Configuración: edita config.php en el servidor: datos de la base (en cPanel suelen tener prefijo, p. ej. usuario_blog), url con https://tudominio.com y debug en false.
  4. Permisos: la carpeta uploads/ debe tener permisos 755 (el servidor necesita escribir en ella). Los archivos PHP, 644. Nunca uses 777.
  5. PHP: en Seleccionar versión de PHP elige 8.1 o superior y verifica que gd y fileinfo estén activas. Si quieres imágenes más grandes, sube upload_max_filesize y post_max_size.
  6. SSL: activa el certificado gratuito (SSL/TLS Status → Run AutoSSL) y descomenta las líneas de HTTPS del .htaccess.
  7. Crea el administrador con crear-admin.php y bórralo.

Checklist de seguridad ​

✔MedidaDónde
☐Consultas preparadas en todas las queriesTodo el proyecto (PDO)
☐Salida escapada con e()Vistas
☐Contraseñas con password_hash / password_verifylogin.php, crear-admin.php
☐session_regenerate_id(true) al iniciar sesiónlogin.php
☐CSRF en todos los formularios POST, logout incluidohelpers.php
☐Límite de intentos de login por IPauth.php
☐Imágenes validadas por MIME real y re-codificadasimagenes.php
☐PHP deshabilitado en uploads/uploads/.htaccess
☐includes/ y config.php inaccesibles desde el navegador.htaccess
☐debug en false en producciónconfig.php
☐crear-admin.php eliminadoServidor
☐HTTPS forzado.htaccess
☐Respaldos automáticos de base de datos y uploads/cPanel → Copias de seguridad

Ideas para ampliarlo ​

  • Editor enriquecido (TinyMCE o Quill) en lugar de texto con formato simple. Si guardas HTML, sanitízalo con HTML Purifier antes de guardarlo.
  • Comentarios con moderación usando el esquema de blog.
  • Etiquetas (relación muchos a muchos) y posts relacionados por etiquetas.
  • Sitemap XML generado con PHP para Google (sitemap.php que liste todos los slugs).
  • Feed RSS para suscriptores.
  • Recuperar contraseña con el snippet de token por correo.
  • Roles más finos con el snippet de permisos.
  • Combinar el panel con el diseño del Dashboard admin.

Entrega profesional

Al entregar al cliente: graba un video corto de cómo crear y editar un post, déjale un usuario editor para el día a día (no el admin) y programa respaldos semanales. Eso evita el 90 % de las llamadas de soporte.

Academia Web — aprende, copia y construye.