Skip to content

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.

Academia Web — aprende, copia y construye.