Tema
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,fileinfoymbstring(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.phpPaso 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);
}
}Plantilla pública (header y footer)
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 deniedapache
# 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 -IndexesSi 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
- Copia la carpeta
blog/dentro dehtdocs(XAMPP) owww(Laragon). - Crea la base de datos e importa
schema.sql. - Ajusta
config.php(url=http://localhost/blog) y descomentaRewriteBase /blog/en.htaccess. - Abre
http://localhost/blog/crear-admin.php, crea tu usuario y borra el archivo. - Entra a
http://localhost/blog/admin/login.phpy publica tu primer post.
Paso 10: subir a un hosting compartido
- 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.sqldesde phpMyAdmin → Importar. - Archivos: sube todo el contenido de
blog/apublic_html/con el Administrador de archivos (sube un.zipy descomprímelo) o por FTP con FileZilla. - Configuración: edita
config.phpen el servidor: datos de la base (en cPanel suelen tener prefijo, p. ej.usuario_blog),urlconhttps://tudominio.comydebugenfalse. - Permisos: la carpeta
uploads/debe tener permisos755(el servidor necesita escribir en ella). Los archivos PHP,644. Nunca uses777. - PHP: en Seleccionar versión de PHP elige 8.1 o superior y verifica que
gdyfileinfoestén activas. Si quieres imágenes más grandes, subeupload_max_filesizeypost_max_size. - SSL: activa el certificado gratuito (SSL/TLS Status → Run AutoSSL) y descomenta las líneas de HTTPS del
.htaccess. - Crea el administrador con
crear-admin.phpy bórralo.
Checklist de seguridad
| ✔ | Medida | Dónde |
|---|---|---|
| ☐ | Consultas preparadas en todas las queries | Todo el proyecto (PDO) |
| ☐ | Salida escapada con e() | Vistas |
| ☐ | Contraseñas con password_hash / password_verify | login.php, crear-admin.php |
| ☐ | session_regenerate_id(true) al iniciar sesión | login.php |
| ☐ | CSRF en todos los formularios POST, logout incluido | helpers.php |
| ☐ | Límite de intentos de login por IP | auth.php |
| ☐ | Imágenes validadas por MIME real y re-codificadas | imagenes.php |
| ☐ | PHP deshabilitado en uploads/ | uploads/.htaccess |
| ☐ | includes/ y config.php inaccesibles desde el navegador | .htaccess |
| ☐ | debug en false en producción | config.php |
| ☐ | crear-admin.php eliminado | Servidor |
| ☐ | 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.phpque 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.