edulazaro / larablog
Markdown blogs for Laravel in several languages, each with its own URL: one folder per post, one file per language, hreflang, feeds, sitemap and social cards included
Requires
- php: ^8.2
- edulazaro/laracards: ^1.1
- laravel/framework: >=11.0
- league/commonmark: ^2.4
- symfony/yaml: ^7.0|^8.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
- edulazaro/laralang: Localized routes: each language's blog under its own prefix and words
Provides
None
Conflicts
None
Replaces
None
README
Larablog
Markdown blogs for Laravel in several languages, where every language has its own URL.
One folder per post, one file per language. Each file declares its own slug, so the English
post lives at /blog/chat-moderation-games and the Spanish one at
/es/blog/moderacion-chat-videojuegos, and each page knows the other: hreflang, a language
switcher, the sitemap and a link shared in the wrong language all just work.
- Posts are Markdown files with YAML frontmatter, read once and cached until a file changes
- A URL per language, with translated words (
/blog/category/x,/es/blog/categoria/x) - A slug from another language redirects to the right post in this one (301)
- Categories and authors in two YAML files, named per language
- Drafts and scheduled posts, an RSS feed per language, sitemap entries with alternates
- Anchors on every heading for an "On this page" index, safe external links
- Links between posts by folder (
post:chat-moderation), resolved in the reader's language - A social card per post and language, drawn by Laracards
php artisan larablog:checkbefore a visitor finds the broken post- Your views: Larablog finds and paginates the posts, your site draws them
Requirements
PHP 8.2+ and Laravel 12 or 13 (tested on both). Laralang is optional: with it the blog shares the site's language prefixes.
Install
composer require edulazaro/larablog php artisan vendor:publish --tag=larablog-config
Content
content/blog/
_authors.yml
_categories.yml
chat-moderation/
en.md
es.md
glossaries/
en.md
The folder name groups the translations and never appears in a URL. A language missing for a
post is allowed; larablog:check points it out.
--- title: "Chat moderation for games" slug: chat-moderation-games description: "Catching toxicity without killing the banter." date: 2026-09-01 updated: 2026-09-20 category: engineering tags: [gaming, moderation] author: edu image: /img/covers/chat.jpg # optional: otherwise the Laracards card is used featured: true draft: false --- The body, in Markdown.
slug defaults to the title, slugified. A post dated in the future is scheduled: it is not
listed, not in the feed or the sitemap, and answers 404 until its day. Any other field stays
available as $post->meta('field').
# _categories.yml engineering: name: { en: Engineering, es: Ingeniería } description: { en: How it is built, es: Cómo está hecho } color: "#009e4d"
# _authors.yml edu: name: Eduardo Lázaro role: { en: Founder, es: Fundador } avatar: /img/authors/edu.webp links: { github: https://github.com/edulazaro }
A category slug is its name, slugified, per language (ingenieria), unless you give slug.
Any other field of a category stays available as $category->meta('field'), in the page's
language when it is written per language. accent is also what the social card is tinted
with, when the color your views use is a class name rather than a colour:
engineering: name: { en: Engineering, es: Ingeniería } color: teal # your CSS: class="cat-{{ $category->color }}" accent: "#2dd4bf" # the card
Linking posts
Link another post by its folder, not by its slug:
Read [how glossaries are applied](post:glossaries) first, or [the part on placeholders](post:glossaries#placeholders).
On an English page that becomes /blog/glossaries-that-are-obeyed, on a Spanish one
/es/blog/glosarios-que-se-cumplen, and if the post is not translated it points at the
language it does exist in. Renaming a slug never breaks the links to it. #anchor and ?query
are kept.
A link to a post that exists but is not out yet (dated ahead, or a draft) is printed as its
words, with no link, and becomes a link by itself the day that post goes live: a published post
can point at one scheduled for next month with no dead link in between. A link to a folder that
does not exist points at # and fails larablog:check.
Routes
// routes/web.php use EduLazaro\Larablog\Larablog; Larablog::routes('blog');
That registers, per language, the listing, a post, a category, a tag and the feed, named
{locale}.blog.index|show|category|tag|feed. With Laralang
installed the language prefixes are Laralang's, so the blog shares them with the rest of the
site; without it the default language sits at the root and the others under their code.
// config/larablog.php 'collections' => [ 'blog' => [ 'path' => base_path('content/blog'), 'locales' => ['en', 'es'], 'paths' => ['en' => 'blog', 'es' => 'blog'], 'segments' => [ 'category' => ['en' => 'category', 'es' => 'categoria'], 'tag' => ['en' => 'tag', 'es' => 'etiqueta'], ], 'views' => ['index' => 'blog.index', 'show' => 'blog.show', 'category' => 'blog.index', 'tag' => 'blog.index'], 'per_page' => 12, 'cards' => ['enabled' => true, 'template' => 'post', 'path' => 'img/blog'], ], ],
Several collections (a blog, guides, case studies) each get their own block and their own
Larablog::routes('guides').
Views
Every view gets $blog, $locale and $alternates (language => URL of this same page).
The listing gets $posts (a paginator), $categories, and $category or $tag when
filtered; a post gets $post and $related.
@foreach ($alternates as $locale => $url) <link rel="alternate" hreflang="{{ $locale }}" href="{{ $url }}"> @endforeach <h1>{{ $post->title }}</h1> <p>{{ $post->author()?->name }} · {{ $post->date->locale($locale)->isoFormat('LL') }} · {{ $post->readingTime }} min</p> <nav> @foreach ($post->headings() as $heading) <a href="#{{ $heading['id'] }}">{{ $heading['text'] }}</a> @endforeach </nav> <article>{!! $post->html() !!}</article>
From PHP
$blog = Larablog::collection('blog'); $blog->posts('es'); // published, newest first $blog->find('moderacion-chat-videojuegos', 'es'); $post->alternates(); // ['en' => '…/blog/chat-moderation-games', 'es' => '…'] $post->translations(); // the same post in the other languages $post->related(3); $blog->categories('es'); $blog->tags('en'); $blog->sitemap(); // every URL with lastmod and its alternates
Social cards
Larablog registers itself as a Laracards source, so
php artisan cards:generate draws one card per post and language, at
public/img/blog/{locale}/{slug}.png. The template gets title, description,
category_label, accent (the category's colour), author_name, date_formatted (in the
post's language), reading_time, brand_label and locale. $post->imageUrl() returns the
frontmatter's image, or the card when it exists.
Checking
php artisan larablog:check
php artisan larablog:check --pending # also list links waiting for their post, with the day
Errors (a post without a title, a slug used twice in one language, broken YAML) are posts
that are not published at all, and a post: link to a folder that does not exist, in any post,
scheduled ones included. A link to a post not out yet is not an error. Warnings are posts
missing a language. It exits 1 on errors, for CI.
Testing
composer install vendor/bin/phpunit
The suite runs on Testbench against fixtures in tests/fixtures/blog: a translated post, one
with no translation, a scheduled one, a draft, a post with no title and a duplicated slug.
GitHub Actions runs it on PHP 8.2 to 8.4 with Laravel 12 and 13 on every push.
Sponsors
Larablog is supported by the following sponsors. Thank you for keeping it growing:
Author
Created by Edu Lazaro
License
Larablog is open-sourced software licensed under the MIT license.
