En este tutorial vamos a ver cómo crear un blog con Laravel y Markdown en el que los artículos, las categorías y los autores se guardan en archivos. Podrás escribir en Markdown, mantener el contenido en Git y generar las portadas automáticamente.
Contenidos
Usaremos Larablog para leer y organizar el contenido y Laracards para preparar las imágenes de cada entrada. Laravel servirá las páginas mediante vistas Blade, con un diseño sencillo en Tailwind CSS. El proyecto funciona sin base de datos y necesita PHP en el servidor de publicación.
Introducción
Vamos a crear un pequeño blog llamado Cuaderno, con un listado de artículos, páginas de categorías y etiquetas, un RSS y un sitemap. También dejaremos preparados los metadatos para compartir las entradas y los ejemplos de borradores y publicaciones programadas.
Si todavía no has utilizado Markdown, se trata de un lenguaje de marcado que permite dar formato al texto con una sintaxis sencilla. Por ejemplo, puedes crear encabezados con #, listas con guiones y enlaces entre corchetes y paréntesis. Los artículos se guardarán en archivos Markdown que podrás editar con tu editor de texto habitual.
Laravel es el framework PHP con el que vamos a construir la aplicación. Al abrir una entrada del blog, Laravel utilizará Larablog para obtener su contenido y mostrarlo mediante una vista Blade. De este modo podremos crear un blog con Markdown sin guardar los artículos en una base de datos.
Antes de continuar, es recomendable que tengas ciertos conocimientos de PHP, HTML y CSS, así como de la línea de comandos. Para repasar estos temas y aprender a escribir los artículos en Markdown, puedes consultar las siguientes guías:
- Aprende PHP: Guía definitiva de PHP.
- Aprende HTML: Guía definitiva de HTML.
- Aprende CSS: Tutoriales de CSS.
- Aprende a usar la terminal: Qué es la línea de comandos y cómo se utiliza.
- Aprende Markdown: Tutorial Markdown.
Puedes consultar el proyecto completo en GitHub. Este es el aspecto de su página principal:

Instala Laravel y los paquetes
El ejemplo está probado con Laravel 13, PHP 8.4 y Node 24. Necesitarás Composer para las dependencias PHP y npm para compilar los estilos. Más adelante veremos cómo arrancar el proyecto completo con Docker.
Lo primero que vamos a hacer es crear la aplicación de Laravel. Para ello, abre una terminal en el directorio donde quieras guardar el proyecto y ejecuta los siguientes comandos:
composer create-project laravel/laravel mi-blog "^13.0" --no-scripts
cd mi-blog
cp .env.example .env
Una vez creada la aplicación, vamos a preparar su configuración. Abre el archivo .env que acabamos de copiar y modifica los siguientes valores:
APP_NAME="Cuaderno"
APP_URL=http://localhost:8099
APP_LOCALE=es
APP_FALLBACK_LOCALE=es
CACHE_STORE=file
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
Así la caché utilizará archivos y el proyecto podrá funcionar sin consultar una base de datos. El blog es público y sus páginas pueden renderizarse sin mantener una sesión de usuario.
A continuación vamos a generar la clave de la aplicación. Laravel la guarda en APP_KEY y la utiliza en sus funciones de cifrado:
php artisan key:generate
Antes de instalar los paquetes, conviene ver qué hace cada uno y por qué los vamos a utilizar:
-
Larablog es un paquete que permite crear blogs en Laravel a partir de archivos Markdown. Cada artículo contiene el texto y unos datos iniciales, como el título, la fecha o el autor. Larablog lee esos archivos y prepara los datos que necesitan nuestras vistas para mostrar tanto las entradas como el listado del blog.
También se encarga de las categorías, las etiquetas, la paginación, los borradores, las publicaciones programadas y el RSS. Por ejemplo, cuando añadamos un artículo a su carpeta, podrá aparecer en el listado y en su categoría sin que tengamos que crear una ruta para él. Nosotros seguiremos definiendo el aspecto de las páginas mediante Blade y Tailwind.
-
Laracards es el paquete que utilizaremos para generar las portadas de los artículos. Parte de una plantilla SVG e introduce en ella el título, el autor y otros datos de cada entrada. Después guarda el resultado como una imagen que podremos mostrar en las tarjetas del blog y al compartir el enlace en redes sociales.
En nuestro ejemplo, todas las portadas tendrán el mismo diseño, con el título y el color de su categoría. Larablog proporciona los datos del artículo y Laracards genera su imagen. Así nos ahorramos abrir Photoshop por cada entrada. Que luego entras para cambiar una letra y, dos horas después, estás rediseñando el logo.
Ahora que sabemos para qué sirven, vamos a instalarlos con Composer:
composer require edulazaro/larablog:^1.0 edulazaro/laracards:^1.2
Para poder adaptar su configuración y el diseño de las portadas, vamos a copiar los archivos que incluyen los paquetes a nuestro proyecto:
php artisan vendor:publish --tag=larablog-config
php artisan vendor:publish --tag=laracards-config
php artisan vendor:publish --tag=laracards-templates
En estos comandos, publicar significa copiar los archivos del paquete para que podamos editarlos. Cada opción prepara una parte del proyecto:
larablog-configcreaconfig/larablog.php, donde indicaremos la carpeta de los artículos, las rutas, los idiomas y las vistas del blog.laracards-configcreaconfig/laracards.php, donde configuraremos las plantillas, las fuentes y la generación de imágenes.laracards-templatescopia las plantillas SVG de ejemplo aresources/cards/, que utilizaremos como punto de partida para nuestras portadas.
Más adelante iremos modificando estos archivos. Por ahora, nos queda instalar las dependencias del frontend, que necesitaremos para compilar los estilos con Vite:
npm install
Puedes consultar las opciones completas en la documentación de Larablog y Laracards. El repositorio incluye composer.lock y package-lock.json con las versiones utilizadas.
Organiza el contenido
Ya tenemos instaladas las herramientas, así que vamos a preparar los artículos que leerá Larablog. En el ejemplo tendremos tres entradas distintas, escritas en español, y guardaremos cada una en su propia carpeta dentro de content/blog/. También crearemos dos archivos compartidos con los datos de los autores y las categorías. Esta será la estructura del proyecto:
content/blog/
_authors.yml
_categories.yml
un-blog-en-tu-repositorio/
es.md
escribir-con-markdown/
es.md
portadas-que-acompanan/
es.md
Vamos a ver qué representa cada elemento:
_authors.ymlcontiene los datos de los autores del blog._categories.ymlcontiene las categorías que podremos asignar a las entradas.un-blog-en-tu-repositorio/,escribir-con-markdown/yportadas-que-acompanan/son las carpetas de los tres artículos del ejemplo.- Cada archivo
es.mdcontiene el texto y los metadatos de su artículo en español. Son tres archivos distintos, cada uno dentro de su carpeta.
El nombre de la carpeta sirve como identificador interno del artículo. Su URL la determina el campo slug que escribiremos dentro de es.md. En nuestro ejemplo coinciden, pero podrías tener una carpeta llamada primer-articulo y escribir slug: mi-primer-post en su archivo: la URL sería /mi-primer-post.
Si más adelante quieres traducir una entrada al inglés, guardarías su traducción en un archivo en.md dentro de la misma carpeta:
content/blog/un-blog-en-tu-repositorio/
es.md
en.md
En ese caso tendríamos un mismo artículo con dos traducciones. Cada archivo tendría su propio título, contenido y slug, que podrías escribir en su idioma. También tendrías que habilitar en en la configuración de Larablog. En este tutorial utilizaremos únicamente español, por eso cada carpeta contiene solo es.md.
Define el autor y las categorías
Antes de escribir una entrada, vamos a definir quién la firma. Los datos del autor se guardan en content/blog/_authors.yml, de modo que podamos reutilizarlos en todos sus artículos. Crea este archivo y añade el siguiente contenido:
edu:
name: Edu Lázaro
bio: Escribo sobre desarrollo web y herramientas para crear proyectos.
links:
website: https://edulazaro.com
Seguidamente vamos a crear las categorías del blog. Para ello, añade el archivo content/blog/_categories.yml con los siguientes datos. Además del nombre y la descripción, indicaremos un color que utilizaremos más adelante en las portadas:
desarrollo:
name: Desarrollo
description: Proyectos, código y decisiones al crear una web.
color: "#6553c7"
escritura:
name: Escritura
description: Ideas para escribir y organizar contenidos.
color: "#b76236"
Escribe el primer artículo
Una vez definidos el autor y las categorías, ya podemos escribir la primera entrada del blog. Vamos a crear un artículo de ejemplo que nos servirá para comprobar tanto el listado como la página con su contenido completo.
Para ello, crea la carpeta un-blog-en-tu-repositorio dentro de content/blog/ y añade en su interior un archivo llamado es.md. En la parte superior indicaremos los datos de la entrada y, a continuación, escribiremos el texto en Markdown. Este sería el contenido del archivo:
---
title: "Un blog que cabe en tu repositorio"
slug: un-blog-en-tu-repositorio
description: "Artículos en Markdown, cambios en Git y un proyecto sencillo de mantener."
date: 2026-10-28
category: desarrollo
tags: [laravel, markdown]
author: edu
draft: false
---
Un blog pequeño puede empezar con una carpeta de artículos y un buen diseño. Guardar el contenido junto al código permite revisar cada cambio y recuperar versiones anteriores.
## Una carpeta por artículo
Cada entrada contiene sus datos al principio del archivo y el texto a continuación. El título, la fecha y la categoría viajan con el contenido.
```markdown
---
title: Mi primer artículo
slug: mi-primer-articulo
date: 2026-10-28
category: desarrollo
author: edu
---
Aquí empieza el artículo.
```
## Publica con calma
Puedes preparar un borrador, comprobar su aspecto y publicarlo cuando esté listo. Un cambio en Git conserva tanto el texto como la decisión de publicarlo.
## Conserva una copia
Guarda los archivos y las imágenes en el repositorio. Así tendrás una copia completa del contenido junto al proyecto que lo muestra.
Como puedes ver, el archivo se divide en dos partes. El bloque inicial entre --- contiene los metadatos de la entrada, escritos en YAML. Ahí hemos indicado su título, la descripción, la fecha, la categoría y el autor. Los campos author y category deben coincidir con las claves que definimos en los archivos anteriores.
A continuación viene el artículo escrito en Markdown. Puedes añadir títulos, párrafos, listas y bloques de código, tal y como hemos hecho en el ejemplo. Cuando se abra la entrada, Larablog convertirá ese contenido a HTML para mostrarlo en el navegador. Las directivas de Blade y los elementos que comparten las páginas, como la cabecera y el pie, los guardaremos en sus vistas correspondientes.
Puedes preparar más entradas utilizando el mismo formato. El proyecto incluye tres publicadas, un borrador y una entrada programada para que puedas comprobar su comportamiento.
Edita los artículos en local o desde el navegador
Una de las ventajas de guardar los artículos en Markdown es que podemos elegir dónde escribirlos. Una vez creado el archivo, tendrás varias opciones para continuar editándolo:
- En local con VS Code: abre la carpeta del proyecto y edita el archivo
content/blog/un-blog-en-tu-repositorio/es.md. Puedes utilizar la vista previa de Markdown para revisar el texto mientras escribes y guardar los cambios en Git cuando termines. - Desde GitHub: si tienes el proyecto en un repositorio, puedes abrir el archivo Markdown en su interfaz web, editarlo y guardar un commit en la rama correspondiente. Para modificar un repositorio propio necesitarás permisos de escritura.
- Con Editor Markdown: este editor gratuito permite escribir con vista previa desde el navegador y guardar los cambios en GitHub. Con una cuenta puedes conectar GitHub, elegir el repositorio y la rama, abrir el archivo del artículo y guardarlo mediante un commit. Tienes los pasos en la guía para editar archivos Markdown de GitHub.
En cualquiera de los casos, conserva el bloque YAML del principio del documento y guarda el archivo en su carpeta y con el nombre de idioma correspondiente, como es.md. Para que la nueva versión aparezca en el blog, lleva los cambios al servidor mediante tu despliegue habitual. Si lo tienes automatizado desde GitHub, el commit podrá iniciar ese despliegue.
Configura la colección del blog
Ahora necesitamos decirle a Larablog dónde hemos guardado los artículos y cómo queremos mostrarlos. Para ello vamos a definir una colección llamada blog, que agrupará el contenido y la configuración de sus páginas.
Abre el archivo config/larablog.php que publicamos durante la instalación y reemplaza su contenido por el siguiente:
<?php
return [
'collections' => [
'blog' => [
'path' => base_path('content/blog'),
'locales' => ['es'],
'paths' => ['es' => ''],
'segments' => ['category' => ['es' => 'categoria'], 'tag' => ['es' => 'etiqueta']],
'views' => ['index' => 'blog.index', 'show' => 'blog.show', 'category' => 'blog.index', 'tag' => 'blog.index'],
'per_page' => 6,
'cards' => ['enabled' => true, 'template' => 'post', 'path' => 'images/blog', 'brand' => 'CUADERNO'],
'feed' => ['enabled' => true, 'title' => 'Cuaderno', 'description' => 'Apuntes sobre desarrollo y escritura.', 'limit' => 20],
],
],
'show_drafts' => false,
'show_scheduled' => false,
'cache' => ['enabled' => env('LARABLOG_CACHE', true), 'store' => null],
'markdown' => ['html_input' => 'strip', 'allow_unsafe_links' => false, 'external_links' => true],
];
La colección blog lee content/blog. El idioma es es y su ruta está vacía, por lo que el listado aparecerá en /. Cada página mostrará hasta seis artículos.
Las categorías utilizarán /categoria/ y las etiquetas /etiqueta/. Las vistas se llaman blog.index y blog.show, que crearemos a continuación. El apartado cards conecta esta colección con la plantilla de portadas.
La lectura de los archivos Markdown queda a cargo de Larablog, que utiliza CommonMark para interpretar su contenido. El paquete mantiene un índice en caché y lo reconstruye cuando cambian los archivos, de modo que podamos añadir o editar entradas sin preparar el listado a mano. La opción html_input: strip elimina las etiquetas HTML escritas dentro de los artículos; las plantillas Blade se encargan del diseño del blog.
Crea el sitemap y registra las rutas
El blog también tendrá un sitemap, que reúne las URLs de sus páginas para facilitar que los buscadores las encuentren. Larablog proporciona las direcciones de los artículos publicados y sus fechas; nosotros vamos a utilizar esos datos para generar el documento XML.
Lo primero que vamos a hacer es crear un controlador para esta tarea. Ejecuta el siguiente comando desde la raíz del proyecto:
php artisan make:controller SitemapController --invokable
Esto creará el archivo app/Http/Controllers/SitemapController.php. En su método __invoke vamos a recorrer las entradas del sitemap y añadir sus direcciones y fechas al XML. Edita el archivo y agrega el siguiente código:
<?php
namespace App\Http\Controllers;
use DOMDocument;
use EduLazaro\Larablog\Larablog;
use Illuminate\Http\Response;
class SitemapController extends Controller
{
public function __invoke(): Response
{
$xml = new DOMDocument('1.0', 'UTF-8');
$root = $xml->appendChild($xml->createElement('urlset'));
$root->setAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');
foreach (Larablog::collection('blog')->sitemap() as $entry) {
$url = $root->appendChild($xml->createElement('url'));
$url->appendChild($xml->createElement('loc'))->appendChild($xml->createTextNode($entry['loc']));
if ($entry['lastmod'] !== null) {
$url->appendChild($xml->createElement('lastmod'))->appendChild($xml->createTextNode($entry['lastmod']));
}
}
return response($xml->saveXML(), 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
}
}
Los nodos de texto permiten incluir las URLs en XML correctamente. El método sitemap() obtiene los artículos publicados de la colección, con la fecha de actualización o publicación que corresponde a cada uno.
Una vez creado el controlador, vamos a registrar tanto su ruta como las del blog. Abre el archivo routes/web.php y añade el siguiente contenido:
<?php
use App\Http\Controllers\SitemapController;
use EduLazaro\Larablog\Larablog;
use Illuminate\Support\Facades\Route;
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
Larablog::routes('blog');
La ruta del sitemap se registra primero porque después Larablog añade la ruta de los artículos por su slug. Con esa llamada al paquete tendrás el listado, las entradas, las categorías, las etiquetas y el RSS.
Para comprobar qué rutas ha registrado Laravel, puedes ejecutar el siguiente comando:
php artisan route:list
Prepara las vistas Blade
Vamos a separar la plantilla general del listado y de la página de cada artículo. De este modo podrás cambiar el diseño manteniendo la misma estructura del contenido.
Crea la plantilla general
Lo primero que vamos a preparar es la plantilla que compartirán todas las páginas del blog. En ella incluiremos la cabecera, el pie y los datos que aparecen en el head del documento. Crea el archivo resources/views/layouts/blog.blade.php y añade este contenido:
<!doctype html>
<html lang="{{ $locale }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', config('app.name'))</title>
<meta name="description" content="@yield('description', 'Apuntes sobre desarrollo y escritura. Un lugar para guardar lo aprendido.')">
<link rel="canonical" href="{{ url()->current() }}">
<link rel="alternate" type="application/rss+xml" title="Cuaderno" href="{{ route('es.blog.feed') }}">
@foreach ($alternates as $language => $alternate)
<link rel="alternate" hreflang="{{ $language }}" href="{{ $alternate }}">
@endforeach
<meta property="og:type" content="{{ isset($post) ? 'article' : 'website' }}">
<meta property="og:title" content="@yield('title', config('app.name'))">
<meta property="og:description" content="@yield('description', 'Apuntes sobre desarrollo y escritura.')">
<meta property="og:url" content="{{ url()->current() }}">
@isset($post)
@if($post->imageUrl())<meta property="og:image" content="{{ url($post->imageUrl()) }}">@endif
<meta property="article:published_time" content="{{ $post->date->toAtomString() }}">
<meta property="article:modified_time" content="{{ ($post->updated ?? $post->date)->toAtomString() }}">
@endisset
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
<header class="site-header">
<a class="brand" href="{{ route('es.blog.index') }}"><span class="brand-mark">c.</span> Cuaderno</a>
<nav aria-label="Principal"><a href="{{ route('es.blog.index') }}">Artículos</a><a href="{{ route('es.blog.feed') }}">RSS <span aria-hidden="true">↗</span></a></nav>
</header>
<main>@yield('content')</main>
<footer class="site-footer"><a class="brand" href="{{ route('es.blog.index') }}">Cuaderno</a><p>Un lugar para guardar lo aprendido.</p><a href="{{ route('sitemap') }}">Mapa del sitio</a></footer>
</body>
</html>
La plantilla incluye el título, la descripción, la URL canónica y los metadatos Open Graph. En los artículos también añade la imagen de la portada y las fechas de publicación y modificación.
Las variables $locale, $alternates y $blog llegan desde Larablog. @vite carga los archivos que compilaremos con npm.
Muestra el listado de artículos
A continuación vamos a crear la página que mostrará las entradas del blog. Cada tarjeta tendrá su portada, el título, un resumen y un enlace al artículo completo. Para ello, crea el archivo resources/views/blog/index.blade.php con el siguiente código:
@extends('layouts.blog')
@section('title', ($category?->name ?? $tag ?? 'Apuntes de desarrollo y escritura').' · Cuaderno')
@section('content')
<section class="intro">
<p class="eyebrow">EL CUADERNO DE EDU</p>
<h1>{{ $category?->name ?? $tag ?? 'Ideas, código y un poco de tinta.' }}</h1>
<p class="intro-lead">{{ $category?->description ?? 'Apuntes sobre desarrollo web, proyectos pequeños y cosas que merece la pena escribir.' }}</p>
</section>
<nav class="category-nav" aria-label="Categorías">
<a @class(['active' => !$category && !$tag]) href="{{ route('es.blog.index') }}">Todos los artículos</a>
@foreach ($categories as $item)
<a @class(['active' => $category?->key === $item->key]) href="{{ $item->url() }}">{{ $item->name }}</a>
@endforeach
</nav>
<section class="post-grid" aria-label="Artículos">
@forelse ($posts as $entry)
<article class="post-card">
<a class="cover-link" href="{{ $entry->url() }}"><img src="{{ $entry->imageUrl() }}" alt="Portada de {{ $entry->title }}" width="1200" height="630" loading="lazy"></a>
<div class="card-content">
<div class="post-meta"><span>{{ $entry->category()?->name }}</span><time datetime="{{ $entry->date->toDateString() }}">{{ $entry->date->translatedFormat('j M Y') }}</time></div>
<h2><a href="{{ $entry->url() }}">{{ $entry->title }}</a></h2>
<p>{{ $entry->description }}</p>
<a class="read-link" href="{{ $entry->url() }}">Leer artículo <span aria-hidden="true">↗</span></a>
</div>
</article>
@empty
<p>Todavía no hay artículos en esta sección.</p>
@endforelse
</section>
<div class="pagination">{{ $posts->links() }}</div>
@endsection
Esta vista sirve para el inicio, las categorías y las etiquetas. $posts es un paginador; cada entrada dispone de su título, resumen, fecha, categoría y URL.
imageUrl() devuelve la imagen indicada en el artículo o la portada generada cuando está disponible. Por eso generaremos las tarjetas antes de comprobar el resultado en el navegador.
Muestra el artículo completo
Ya tenemos el listado, así que vamos a preparar la página a la que llegará el lector al abrir una entrada. En ella mostraremos el título, la portada y el contenido del artículo, seguidos de sus etiquetas y los datos del autor. Crea el archivo resources/views/blog/show.blade.php y añade lo siguiente:
@extends('layouts.blog')
@section('title', $post->title.' · Cuaderno')
@section('description', $post->description)
@section('content')
<article class="article-page">
<header class="article-header">
<a class="back-link" href="{{ route('es.blog.index') }}">← Todos los artículos</a>
<div class="post-meta"><a href="{{ $post->category()?->url() }}">{{ $post->category()?->name }}</a><span>{{ $post->readingTime }} min de lectura</span></div>
<h1>{{ $post->title }}</h1>
<p class="article-lead">{{ $post->description }}</p>
<p class="byline">{{ $post->author()?->name }} <span aria-hidden="true">·</span> <time datetime="{{ $post->date->toDateString() }}">{{ $post->date->translatedFormat('j \d\e F \d\e Y') }}</time></p>
<img class="article-cover" src="{{ $post->imageUrl() }}" alt="Portada de {{ $post->title }}" width="1200" height="630">
</header>
<div class="article-content">{!! $post->html() !!}</div>
<div class="tags" aria-label="Etiquetas">@foreach($post->tags as $label)<a href="{{ route('es.blog.tag', ['tag' => \Illuminate\Support\Str::slug($label)]) }}">#{{ $label }}</a>@endforeach</div>
<aside class="author-box"><span class="author-initial">E</span><div><strong>{{ $post->author()?->name }}</strong><p>{{ $post->author()?->bio }}</p></div></aside>
@if($related)
<section class="related"><h2>Para seguir leyendo</h2>@foreach($related as $entry)<a href="{{ $entry->url() }}">{{ $entry->title }} <span aria-hidden="true">↗</span></a>@endforeach</section>
@endif
</article>
@endsection
Lo que hemos hecho ha sido obtener el artículo desde Larablog y mostrarlo dentro de la plantilla general. La llamada a html() devuelve el HTML del contenido escrito en Markdown, con sus encabezados, listas y bloques de código. La configuración que hemos elegido elimina las etiquetas HTML que se hayan introducido directamente en el archivo. Mantén estos archivos bajo tu control si amplías los permisos de edición del proyecto.
Las etiquetas enlazan a sus listados. Al final mostramos los datos del autor y los artículos relacionados que proporciona el paquete.
Añade los estilos con Tailwind
Ahora vamos a dar forma al blog con Tailwind. La aplicación de Laravel que hemos creado incluye su configuración con Vite, que se encarga de compilar los recursos del frontend. Antes de añadir los estilos, comprueba que vite.config.js conserva el plugin de Tailwind y las entradas de nuestros archivos:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
tailwindcss(),
],
server: {
watch: {
ignored: ['**/storage/framework/views/**'],
},
},
});
Vamos a agrupar los estilos de las partes que se repiten, como la cabecera, las tarjetas, el artículo y el pie de página. De este modo podremos reutilizarlos en las vistas y ajustar el diseño desde un mismo archivo. Abre resources/css/app.css y añade el siguiente contenido:
@import 'tailwindcss';
@source '../views/**/*.blade.php';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@theme { --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; }
@layer base {
body { @apply m-0 bg-[#faf9f5] font-sans text-[#26322b] antialiased; }
a { @apply transition-colors; }
a:focus-visible { outline:2px solid #6553c7; outline-offset:5px; }
}
@layer components {
.site-header,.site-footer { @apply mx-auto flex max-w-6xl items-center justify-between gap-6 px-6; }
.site-header { @apply border-b border-[#dedfd5] py-6; }
.brand { @apply flex items-center gap-3 text-xl font-bold tracking-tight; }
.brand-mark { @apply flex size-9 items-center justify-center rounded-xl bg-[#6553c7] text-white; }
.site-header nav { @apply flex gap-6 text-sm font-medium; }
.site-header nav a:hover,.read-link:hover { @apply text-[#6553c7]; }
.intro { @apply mx-auto max-w-6xl px-6 pt-20 pb-12; }
.eyebrow { @apply mb-5 text-xs font-bold tracking-[.18em] text-[#6553c7]; }
.intro h1 { @apply max-w-3xl text-4xl leading-[1.12] font-semibold tracking-tight sm:text-6xl; }
.intro-lead { @apply mt-6 max-w-2xl text-lg leading-8 text-[#68716a]; }
.category-nav { @apply mx-auto flex max-w-6xl flex-wrap gap-3 px-6 pb-9 text-sm; }
.category-nav a { @apply rounded-full border border-[#d9ddd2] px-4 py-2 text-[#5e685f]; }
.category-nav a.active { @apply border-[#26322b] bg-[#26322b] text-white; }
.post-grid { @apply mx-auto grid max-w-6xl gap-7 px-6 sm:grid-cols-2 lg:grid-cols-3; }
.post-card { @apply overflow-hidden rounded-2xl border border-[#dedfd5] bg-white; }
.cover-link { @apply block overflow-hidden border-b border-[#dedfd5] bg-[#f7f5ee]; }
.cover-link img { @apply aspect-[1200/630] w-full object-cover transition-transform duration-300 hover:scale-[1.02]; }
.card-content { @apply p-6; }
.post-meta { @apply flex flex-wrap items-center gap-4 text-xs font-medium text-[#6e766e]; }
.post-meta>span:first-child { @apply text-[#6553c7]; }
.post-card h2 { @apply mt-4 text-xl leading-7 font-semibold tracking-tight; }
.post-card h2 a:hover { @apply text-[#6553c7]; }
.card-content>p { @apply mt-3 text-sm leading-7 text-[#68716a]; }
.read-link { @apply mt-6 flex items-center justify-between text-sm font-semibold; }
.pagination { @apply mx-auto mt-10 max-w-6xl px-6; }
.site-footer { @apply mt-20 flex-wrap border-t border-[#dedfd5] py-8 text-sm text-[#68716a]; }
.article-page { @apply mx-auto max-w-3xl px-6 pt-12; }
.back-link { @apply mb-10 block text-sm font-medium text-[#68716a]; }
.article-header h1 { @apply mt-5 text-4xl leading-[1.15] font-semibold tracking-tight sm:text-5xl; }
.article-lead { @apply mt-5 text-xl leading-8 text-[#68716a]; }
.byline { @apply mt-6 text-sm text-[#68716a]; }
.article-cover { @apply mt-9 mb-10 w-full rounded-2xl border border-[#dedfd5]; }
.article-content { @apply text-lg leading-8; overflow-wrap:anywhere; }
.article-content p,.article-content ul,.article-content ol { @apply my-6; }
.article-content h2 { @apply mt-10 mb-5 text-2xl font-semibold tracking-tight; }
.article-content h3 { @apply mt-8 mb-4 text-xl font-semibold; }
.article-content a { @apply text-[#6553c7] underline underline-offset-4; }
.article-content ul { @apply list-disc pl-7; }
.article-content ol { @apply list-decimal pl-7; }
.article-content li { @apply my-2; }
.article-content pre { @apply my-7 overflow-x-auto rounded-xl bg-[#26322b] p-6 text-sm leading-7 text-[#f1f4ed]; }
.article-content :not(pre)>code { @apply rounded bg-[#ecebe4] px-1.5 py-0.5 text-sm; }
.article-content blockquote { @apply my-7 border-l-4 border-[#6553c7] pl-6 text-[#68716a]; }
.article-content img { @apply h-auto max-w-full rounded-xl; }
.tags { @apply mt-10 flex flex-wrap gap-3 text-sm text-[#6553c7]; }
.author-box { @apply mt-10 flex items-center gap-5 rounded-2xl border border-[#dedfd5] p-6; }
.author-initial { @apply flex size-12 shrink-0 items-center justify-center rounded-full bg-[#e9e5fa] text-xl font-bold text-[#6553c7]; }
.author-box p { @apply mt-1 text-sm leading-6 text-[#68716a]; }
.related { @apply mt-12; }
.related h2 { @apply mb-4 text-xl font-semibold; }
.related a { @apply flex justify-between border-b border-[#dedfd5] py-4 text-[#6553c7]; }
}
El listado pasa de una columna a dos y después a tres según el ancho de la pantalla. En el artículo limitamos el ancho del texto y permitimos desplazar los bloques de código que sean más anchos.
El archivo resources/js/app.js puede contener únicamente un comentario: las páginas del ejemplo funcionan con enlaces y vistas Blade. Compila los recursos con:
npm run build
Si necesitas repasar esta configuración, consulta la guía de instalación de Tailwind en Laravel.
Genera las portadas con Laracards
A continuación vamos a preparar las portadas que hemos utilizado en las vistas. Crearemos una plantilla SVG con un espacio para el título y los datos del artículo. Laracards se encargará de introducir esos datos y ajustar el tamaño del texto para que quepa en la imagen.
Para renderizar SVG necesitarás rsvg-convert. El contenedor del ejemplo ya lo incluye; en Debian o Ubuntu puedes instalarlo con:
sudo apt install librsvg2-bin fonts-dejavu-core
Configura la fuente y la plantilla
El proyecto incluye la fuente DejaVu Sans Bold y su licencia en resources/fonts/. Copia esos archivos desde el repositorio si estás siguiendo el tutorial desde cero. La fuente utilizada para medir el texto debe corresponderse con la que utiliza el SVG.
Lo primero que debemos indicar es qué fuente y qué plantilla utilizaremos. Abre config/laracards.php y reemplaza su contenido por la siguiente configuración:
<?php
return [
'renderer' => 'rsvg',
'fonts' => ['default' => resource_path('fonts/DejaVuSans-Bold.ttf')],
'templates' => [
'post' => [
'file' => 'post.svg',
'fit' => [
'title' => ['font' => 'default', 'x' => 72, 'max_width' => 1020, 'max_lines' => 3,
'sizes' => [78, 68, 58, 48], 'line_height' => 1.2, 'anchor' => 'top', 'baseline' => 272],
],
],
],
];
Seguidamente vamos a crear la plantilla que hemos indicado en la configuración. Abre el archivo resources/cards/post.svg y añade el siguiente contenido. Los campos entre dobles llaves serán reemplazados por los datos de cada artículo:
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#f7f5ee"/>
<rect x="0" y="0" width="22" height="630" fill="{{accent}}"/>
<circle cx="1100" cy="100" r="180" fill="{{accent}}" opacity=".10"/>
<text x="72" y="100" fill="#5442a5" font-family="DejaVu Sans" font-size="25" font-weight="bold">{{brand_label}}</text>
<text x="72" y="170" fill="#5d625e" font-family="DejaVu Sans" font-size="23">{{category_label}}</text>
<text x="72" y="{{title_baseline}}" fill="#202820" font-family="DejaVu Sans" font-weight="bold" font-size="{{title_font_size}}">{{title_tspans}}</text>
<line x1="72" y1="525" x2="1128" y2="525" stroke="#d7d9cf"/>
<text x="72" y="578" fill="#5d625e" font-family="DejaVu Sans" font-size="22">{{author_name}}</text>
<text x="1128" y="578" text-anchor="end" fill="#5d625e" font-family="DejaVu Sans" font-size="22">{{date_formatted}}</text>
</svg>
title_tspans contiene las líneas del título y title_font_size su tamaño calculado. El color de la categoría llega en accent, de modo que puedes mantener una misma plantilla para todos los artículos.
Una vez preparadas la configuración y la plantilla, ya podemos generar las portadas. Para ello, ejecuta el siguiente comando:
php artisan cards:generate
Larablog se registra como fuente de tarjetas. Las imágenes de esta colección aparecerán en public/images/blog/es/ y se utilizarán en el listado y en los metadatos para compartir.
El comando comprueba los cambios del contenido para decidir qué debe regenerar. Si necesitas volver a dibujarlas todas, utiliza:
php artisan cards:generate --force
Comprueba el blog
Ya tenemos preparados el contenido, las vistas y las portadas. Antes de abrir el blog, vamos a comprobar que los archivos de los artículos sean válidos. Después iniciaremos el servidor de desarrollo de Laravel con los siguientes comandos:
php artisan larablog:check
php artisan serve --port=8099
Ahora ya puedes acceder a http://localhost:8099 desde tu navegador. Primero verás el listado de entradas; desde él podrás abrir un artículo y comprobar los enlaces a su categoría y sus etiquetas. La página con el contenido completo tendrá este aspecto:

Estas son las rutas principales del ejemplo:
| Ruta | Contenido |
|---|---|
/ |
Listado de artículos |
/{slug} |
Entrada del blog |
/categoria/{category} |
Artículos de una categoría |
/etiqueta/{tag} |
Artículos de una etiqueta |
/feed |
RSS |
/sitemap.xml |
Sitemap |
El repositorio contiene pruebas para las páginas públicas, el contenido oculto, las categorías, las etiquetas y los documentos XML. Puedes ejecutarlas con:
php artisan test --compact
Prepara borradores y programa publicaciones
Cuando escribas una entrada nueva, puede que quieras dejarla preparada antes de publicarla. Para mantenerla como borrador, basta con cambiar el campo draft de sus metadatos:
draft: true
Para programarla, utiliza una fecha futura y deja draft: false. Hasta que llegue esa fecha, la entrada queda fuera del listado, el RSS y el sitemap, y su URL devuelve 404.
Puedes generar la portada de una entrada programada antes de publicarla. En el proyecto hay un ejemplo fechado en 2099 para que puedas comprobarlo sin que aparezca en el listado.
Cuando termines de editar, ejecuta larablog:check y vuelve a generar las tarjetas que hayan cambiado. Después podrás guardar el contenido y las imágenes en un commit.
Arranca el proyecto con Docker Compose
Si prefieres trabajar con Docker, el repositorio incluye un contenedor PHP con Composer y el renderizador de portadas, y otro para ejecutar npm con Node 24.
Para utilizar esta configuración vamos a descargar el repositorio del tutorial. Después instalaremos las dependencias y generaremos los recursos dentro de los contenedores. Estos son los pasos que debes seguir:
git clone https://github.com/neoguias/tutorial-blog-laravel-markdown.git
cd tutorial-blog-laravel-markdown
cp .env.example .env
docker compose build app
docker compose run --rm app composer install
docker compose run --rm app php artisan key:generate
docker compose run --rm node npm ci
docker compose run --rm node npm run build
docker compose run --rm app php artisan cards:generate
docker compose up -d app
La aplicación estará disponible en http://localhost:8099. Para detenerla utiliza docker compose down.
Finalizando
Con esto ya hemos visto cómo crear un blog con Laravel y Markdown cuyos artículos, autores y categorías se guardan en archivos. Puedes escribir nuevas entradas, generar sus portadas y guardar los cambios en Git junto con el código de la aplicación.
Para publicarlo configura tu dominio en APP_URL, utiliza APP_ENV=production y APP_DEBUG=false y prepara las dependencias de Composer y los recursos de Vite. El servidor debe servir la carpeta public/ y poder escribir en storage/ y bootstrap/cache/. Si llevas las portadas generadas, puedes prepararlas en tu equipo y conservarlas en Git.
Este proyecto sirve las páginas con Laravel y PHP. Para alojarlo en un servicio que admita únicamente HTML necesitarías añadir una exportación de las páginas.
Como siguientes pasos puedes personalizar las portadas, añadir traducciones o adaptar el diseño a tu web. Tienes todos los archivos en el repositorio del proyecto.
Esto ha sido todo.
0 comentarios en “Cómo crear un blog con Laravel y Markdown”
Entra con tu correo para comentar.