Tema
Esquema de blog
Base de datos para un blog o sitio de noticias: autores, categorías, etiquetas (relación muchos a muchos), publicaciones con estados (borrador, programado, publicado), campos SEO, comentarios con respuestas anidadas y contador de vistas.
Es la base del proyecto Blog/CMS en PHP + MySQL.
Esquema completo
sql
-- ============================================
-- Blog (MySQL 8)
-- ============================================
SET NAMES utf8mb4;
CREATE TABLE autores (
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','autor') NOT NULL DEFAULT 'autor',
bio VARCHAR(500) NULL,
avatar VARCHAR(255) NULL,
slug VARCHAR(120) NOT NULL UNIQUE,
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,
descripcion VARCHAR(255) NULL,
color CHAR(7) NULL DEFAULT '#4f46e5'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE etiquetas (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(60) NOT NULL,
slug VARCHAR(80) NOT NULL UNIQUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE posts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
autor_id INT UNSIGNED NOT NULL,
categoria_id INT UNSIGNED NULL,
titulo VARCHAR(200) NOT NULL,
slug VARCHAR(220) NOT NULL UNIQUE,
extracto VARCHAR(400) NULL,
contenido MEDIUMTEXT NOT NULL,
imagen VARCHAR(255) NULL,
estado ENUM('borrador','programado','publicado','archivado') NOT NULL DEFAULT 'borrador',
publicado_en DATETIME NULL, -- fecha de publicación (o programada)
meta_titulo VARCHAR(70) NULL, -- SEO
meta_descripcion VARCHAR(160) NULL, -- SEO
vistas INT UNSIGNED NOT NULL DEFAULT 0,
permite_comentarios TINYINT(1) NOT NULL DEFAULT 1,
creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
actualizado_en TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_posts_publicados (estado, publicado_en),
KEY idx_posts_categoria (categoria_id),
FULLTEXT KEY ft_posts (titulo, extracto, contenido),
FOREIGN KEY (autor_id) REFERENCES autores(id),
FOREIGN KEY (categoria_id) REFERENCES categorias(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE post_etiqueta (
post_id INT UNSIGNED NOT NULL,
etiqueta_id INT UNSIGNED NOT NULL,
PRIMARY KEY (post_id, etiqueta_id),
KEY idx_pe_etiqueta (etiqueta_id),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (etiqueta_id) REFERENCES etiquetas(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE comentarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_id INT UNSIGNED NOT NULL,
padre_id INT UNSIGNED NULL, -- respuestas a otro comentario
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL,
contenido TEXT NOT NULL,
estado ENUM('pendiente','aprobado','spam') NOT NULL DEFAULT 'pendiente',
ip VARCHAR(45) NULL,
creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_com_post_estado (post_id, estado),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (padre_id) REFERENCES comentarios(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE suscriptores (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(150) NOT NULL UNIQUE,
confirmado TINYINT(1) NOT NULL DEFAULT 0,
token CHAR(64) NOT NULL,
creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================
-- Datos de ejemplo
-- ============================================
INSERT INTO autores (nombre, email, password_hash, rol, slug, bio) VALUES
('Laura Méndez', 'laura@miblog.mx', '$2y$10$reemplaza.este.hash.por.uno.real.generado.con.php.xx', 'admin', 'laura-mendez', 'Desarrolladora web y café-adicta.');
INSERT INTO categorias (nombre, slug, color) VALUES
('Desarrollo web', 'desarrollo-web', '#4f46e5'),
('Negocios', 'negocios', '#059669'),
('Diseño', 'diseno', '#db2777');
INSERT INTO etiquetas (nombre, slug) VALUES ('PHP', 'php'), ('SEO', 'seo'), ('CSS', 'css'), ('Emprendimiento', 'emprendimiento');
INSERT INTO posts (autor_id, categoria_id, titulo, slug, extracto, contenido, estado, publicado_en) VALUES
(1, 1, '5 errores comunes al crear tu primer sitio', '5-errores-primer-sitio',
'Evita estos fallos que cometen casi todos los principiantes.',
'<p>Contenido del artículo...</p>', 'publicado', NOW() - INTERVAL 3 DAY),
(1, 2, 'Cómo cotizar un sitio web para un cliente', 'como-cotizar-sitio-web',
'Una guía práctica para poner precio a tu trabajo sin regalarlo.',
'<p>Contenido del artículo...</p>', 'publicado', NOW() - INTERVAL 1 DAY),
(1, 3, 'Tendencias de diseño para 2027', 'tendencias-diseno-2027',
'Lo que viene en colores, tipografías y microinteracciones.',
'<p>Borrador...</p>', 'programado', NOW() + INTERVAL 7 DAY);
INSERT INTO post_etiqueta VALUES (1, 1), (1, 2), (2, 4), (3, 3);Consultas del blog
sql
-- Portada: últimos posts publicados (los programados aparecen solos al llegar su fecha)
SELECT p.titulo, p.slug, p.extracto, p.imagen, p.publicado_en,
a.nombre AS autor, c.nombre AS categoria, c.slug AS categoria_slug
FROM posts p
JOIN autores a ON a.id = p.autor_id
LEFT JOIN categorias c ON c.id = p.categoria_id
WHERE p.estado IN ('publicado', 'programado') AND p.publicado_en <= NOW()
ORDER BY p.publicado_en DESC
LIMIT 10 OFFSET 0;
-- Un post con sus etiquetas en una sola fila
SELECT p.*, a.nombre AS autor,
GROUP_CONCAT(e.nombre ORDER BY e.nombre SEPARATOR ', ') AS etiquetas
FROM posts p
JOIN autores a ON a.id = p.autor_id
LEFT JOIN post_etiqueta pe ON pe.post_id = p.id
LEFT JOIN etiquetas e ON e.id = pe.etiqueta_id
WHERE p.slug = '5-errores-primer-sitio'
GROUP BY p.id;
-- Sumar una vista
UPDATE posts SET vistas = vistas + 1 WHERE id = 1;
-- Posts de una etiqueta
SELECT p.titulo, p.slug FROM posts p
JOIN post_etiqueta pe ON pe.post_id = p.id
JOIN etiquetas e ON e.id = pe.etiqueta_id
WHERE e.slug = 'php' AND p.estado = 'publicado'
ORDER BY p.publicado_en DESC;
-- Posts relacionados: comparten más etiquetas con el actual
SELECT p.titulo, p.slug, COUNT(*) AS coincidencias
FROM post_etiqueta pe
JOIN posts p ON p.id = pe.post_id
WHERE pe.etiqueta_id IN (SELECT etiqueta_id FROM post_etiqueta WHERE post_id = 1)
AND p.id <> 1 AND p.estado = 'publicado'
GROUP BY p.id
ORDER BY coincidencias DESC, p.publicado_en DESC
LIMIT 3;
-- Nube de etiquetas con conteo
SELECT e.nombre, e.slug, COUNT(pe.post_id) AS total
FROM etiquetas e LEFT JOIN post_etiqueta pe ON pe.etiqueta_id = e.id
GROUP BY e.id HAVING total > 0 ORDER BY total DESC;
-- Archivo por mes (barra lateral)
SELECT DATE_FORMAT(publicado_en, '%Y-%m') AS mes, COUNT(*) AS total
FROM posts WHERE estado = 'publicado'
GROUP BY mes ORDER BY mes DESC;
-- Comentarios aprobados con respuestas (CTE recursiva, MySQL 8+)
WITH RECURSIVE hilo AS (
SELECT id, padre_id, nombre, contenido, creado_en, 0 AS nivel, CAST(LPAD(id, 8, '0') AS CHAR(500)) AS ruta
FROM comentarios WHERE post_id = 1 AND padre_id IS NULL AND estado = 'aprobado'
UNION ALL
SELECT c.id, c.padre_id, c.nombre, c.contenido, c.creado_en, h.nivel + 1, CONCAT(h.ruta, '/', LPAD(c.id, 8, '0'))
FROM comentarios c JOIN hilo h ON c.padre_id = h.id
WHERE c.estado = 'aprobado'
)
SELECT * FROM hilo ORDER BY ruta;
-- Búsqueda por relevancia
SELECT titulo, slug, MATCH(titulo, extracto, contenido) AGAINST ('cotizar cliente') AS relevancia
FROM posts
WHERE estado = 'publicado' AND MATCH(titulo, extracto, contenido) AGAINST ('cotizar cliente')
ORDER BY relevancia DESC;Programar publicaciones sin cron
Con la condición estado IN ('publicado','programado') AND publicado_en <= NOW(), los posts programados aparecen automáticamente al llegar su fecha, sin tareas programadas.
Contenido HTML
Si guardas HTML en contenido (desde un editor como TinyMCE o Quill), sanitízalo al guardar con una librería como HTML Purifier (PHP) o DOMPurify/sanitize-html (Node). Si no, un autor malicioso podría inyectar scripts.