Search by

edulazaro / larablog

edulazaro

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

Package info

github.com/edulazaro/larablog

pkg:composer/edulazaro/larablog

Statistics

Installs: 37

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

1.2.0 2026-11-03 13:21 UTC

This package is auto-updated.

Last update: 2026-11-03 14:04:44 UTC


README

Larablog

Larablog

Tests Latest Stable Version PHP Version License

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:check before 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.

Content that appears when another post is published

Wrap any Markdown fragment in if-published and name the other post by its folder ID:

::: if-published post="chat-moderation"
Read [our complete guide to chat moderation](/blog/chat-moderation-games).
:::

The opening and closing markers must be on their own lines. Everything inside is ordinary Markdown: a paragraph, heading, image, table, list or several elements together. The whole fragment is omitted until that post is published. The links and their visible text remain exactly as you wrote them; this condition does not rewrite ordinary URLs.

A list item can have its own condition. Keep its content and closing marker indented under the list marker:

- ::: if-published post="chat-moderation"
  Moderation guide: [How to moderate a game chat](/blog/chat-moderation-games).
  :::

- ::: if-published post="future"
  Next guide: [The future article](/blog/from-the-future).
  :::

- ::: if-published post="draft"
  Another guide: [The draft article](/blog/half-written).
  :::

If only the first post is published, only the first complete <li> appears, including its label and link. Hidden items leave no empty bullets. Lists with no remaining items are removed as well. A condition inside an item that also has other content hides just that fragment and keeps the rest of the item.

Conditions are evaluated after the HTML cache, on every visit. A scheduled post reveals its dependent fragments when its publication time arrives, without editing the source post, running a command or rebuilding the cached HTML. Hidden headings are excluded from $post->headings() and their anchors remain stable when they become visible.

The target belongs to the same Larablog collection. Availability follows the same language fallback as post: links: the requested translation first, then an available translation. Preview options show_drafts and show_scheduled also apply. Conditions may be nested; all outer conditions must be satisfied. Examples inside fenced code remain literal.

Create the target files before scheduling the series. larablog:check reports missing folder IDs and missing closing markers; --pending lists dependencies waiting for publication. A missing target or unclosed conditional stays hidden.

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.

Index cache

Larablog caches each collection's metadata and a lookup keyed by language and slug. Looking up one post does not instantiate every post in the collection. A request automatically builds a missing index, so no deployment command is required.

In production, filesystem changes are checked at most once every 60 seconds. Outside production they are checked immediately, preserving the edit-and-refresh workflow. Existing published configuration files use these defaults too. To choose another interval:

// config/larablog.php
'cache' => [
    'enabled' => true,
    'store' => null,          // the application's default Laravel cache store
    'check_interval' => 30,  // seconds; 0 checks on every request
],

LARABLOG_CACHE_CHECK_INTERVAL is also available when using the package's configuration. The index is refreshed on the first request after that interval, including additions, deletions, renamed slugs, authors and categories. New metadata can take up to the interval to appear. Draft visibility and scheduled publication times are evaluated on every lookup, so a scheduled post becomes public on time without rebuilding its index.

To prepare an index during deployment, or make edited metadata visible immediately:

php artisan larablog:cache        # all collections
php artisan larablog:cache blog   # one collection

This command is optional. larablog:check also refreshes the index before validating files. Caches supporting atomic locks coordinate refreshes; visitors can use the previous snapshot while another process refreshes it. Custom stores without locks continue to work. With cache.enabled set to false, nothing is persisted and content is read on demand.

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:

Kenodo Kenodo     AndorraDev AndorraDev

Author

Created by Edu Lazaro

License

Larablog is open-sourced software licensed under the MIT license.