develate / blogframe
A static Markdown blog framework for Laravel applications
Requires
- php: ^8.2
- develate/commonmark-customtags: ^1.1
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- intervention/image: ^3.0
- league/commonmark: ^2.8
- symfony/yaml: ^7.2|^8.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
README
Blogframe is a Laravel package for blogs whose articles live in version-controlled Markdown files. It discovers translated articles, parses YAML front matter, renders CommonMark with runtime custom tags, and serves local images through signed responsive-image URLs.
Blogframe is deliberately a content package: your application keeps control of article routes, controllers, Blade views, feeds, and presentation.
Requirements
- PHP 8.2 or newer
- Laravel 12 or 13
- GD or Imagick for responsive image generation
Installation
Install the package with Composer:
composer require develate/blogframe
Laravel discovers BlogframeServiceProvider and the Blogframe facade automatically. Publish the configuration when you need to change the defaults:
php artisan vendor:publish --tag=blogframe-config
Article structure
Articles belong in the host application's resources/blog/articles directory. The directory name supplies the positive numeric ID and name; the Markdown filename supplies the language:
resources/
└── blog/
├── articles/
│ └── 22-beginners-guide-to-seo/
│ ├── de.md
│ └── en.md
└── images/
├── 1.png
└── content/
└── audit.webp
Every translation requires YAML front matter with a non-empty title and image. Other attributes are preserved:
--- title: Einsteigerleitfaden für SEO image: 1.png description: Die wichtigsten Grundlagen für bessere Suchergebnisse. published_at: 2026-07-20 tags: - seo - marketing --- # Willkommen This body is rendered with CommonMark. {{image src=content/audit.webp alt="Screenshot of the SEO audit" class="rounded-xl"}}
The following front-matter keys are reserved because Blogframe derives them: id, name, language, image_url, image_srcset, image_sizes, markdown, html, and source_path.
Invalid filenames, duplicate IDs or translations, malformed YAML, missing required attributes, and missing images throw a descriptive Blogframe exception instead of silently hiding content.
Querying articles
Use the facade from controllers, view models, commands, or application code:
use Develate\Blogframe\Facades\Blogframe; $articles = Blogframe::all('de'); $article = Blogframe::find(22, 'de'); $article = Blogframe::findOrFail(22, 'de');
all() returns an Illuminate\Support\Collection ordered by descending article ID. If no language is supplied, Blogframe uses Laravel's current locale. When a translation is unavailable it tries the configured fallback language, which defaults to app.fallback_locale.
An article is an immutable object with these public properties:
$article->id; $article->name; $article->language; $article->title; $article->image; $article->imageUrl; $article->imageSrcset; $article->imageSizes; $article->attributes; $article->markdown; $article->html; $article->sourcePath; $description = $article->attribute('description'); $html = $article->toHtml();
markdown contains only the body after front matter has been removed. html and toHtml() contain the rendered CommonMark output.
Header and OG images
imageUrl is the signed original image URL and is suitable for Open Graph metadata:
<meta property="og:image" content="{{ $article->imageUrl }}">
For a responsive header, add the generated candidate list and sizes:
<img src="{{ $article->imageUrl }}" @if ($article->imageSrcset) srcset="{{ $article->imageSrcset }}" @endif @if ($article->imageSizes) sizes="{{ $article->imageSizes }}" @endif alt="{{ $article->title }}" >
You can also generate image values directly:
$originalUrl = Blogframe::imageUrl('1.png'); $srcset = Blogframe::imageSrcset('1.png');
The image custom tag
The built-in image tag resolves images relative to resources/blog/images:
{{image src=1.png alt="Homepage preview" class="rounded shadow"}}
With local image serving enabled, the result contains:
- a signed fallback
srcnear the configured fallback width; - a signed
srcsetcontaining only candidates that do not upscale the source; sizes="100vw"by default, overridable with the tag'ssizesattribute;- intrinsic
widthandheight, unless explicitly provided.
The tag accepts src, alt, class, id, title, width, height, loading, decoding, fetchpriority, role, sizes, data-*, and aria-*. Blogframe generates srcset; an authored srcset, positional argument, event handler, style, or unknown attribute is rejected. Attribute values are HTML-escaped.
Registering custom tags
Additional tags are registered at runtime, not in configuration. Register them in your application's AppServiceProvider::boot() method:
namespace App\Providers; use App\Markdown\CalloutTag; use Develate\Blogframe\Facades\Blogframe; use Illuminate\Support\ServiceProvider; final class AppServiceProvider extends ServiceProvider { public function boot(): void { Blogframe::customtag(new CalloutTag()); } }
The identifier returned by the tag is its registry key. A later registration with the same identifier replaces the existing tag and invalidates rendered articles in the current lifecycle:
Blogframe::customtag(new FirstCalloutTag()); // identifier: callout Blogframe::customtag(new NewCalloutTag()); // replaces FirstCalloutTag
The built-in image tag follows the same rule and can be replaced by registering another tag whose identifier is image.
Custom tags extend Develate\CommonmarkCustomtags\Customtag. See develate/commonmark-customtags for its argument syntax and interface.
Configuration
The published config/blogframe.php contains all supported settings:
use Intervention\Image\Drivers\Gd\Driver; return [ 'paths' => [ 'articles' => resource_path('blog/articles'), 'images' => resource_path('blog/images'), ], 'languages' => [ 'default' => null, // app locale 'fallback' => null, // app fallback locale ], 'commonmark' => [ 'options' => [ 'html_input' => 'strip', 'allow_unsafe_links' => false, ], 'extensions' => [], ], 'images' => [ 'serve' => true, 'base_url' => null, 'route_prefix' => 'blogframe/images', 'middleware' => [], 'allowed_extensions' => ['png', 'jpg', 'jpeg', 'webp', 'gif', 'avif'], 'widths' => [320, 640, 960, 1280, 1600], 'fallback_width' => 960, 'sizes' => '100vw', 'driver' => Driver::class, 'quality' => 82, 'cache_path' => storage_path('framework/cache/blogframe/images'), 'cache_control' => 'public, max-age=31536000, immutable', ], ];
Configured CommonMark extension classes are resolved through Laravel's container. Blogframe always owns the single CustomtagExtension; register individual tags with Blogframe::customtag() instead of configuring another custom-tag extension.
Use Intervention\Image\Drivers\Imagick\Driver when Imagick is installed and preferred. An allowed source format must also be supported by the selected driver. SVG is intentionally unsupported.
Signed responsive image routes
When images.serve is enabled, Blogframe registers this named route:
{route_prefix}/{source-fingerprint}/{width-or-original}/{image-path}
Every URL has a permanent relative Laravel signature. The source fingerprint, requested width, and path are covered by that signature. The route additionally rejects widths outside the configured allowlist, upscaling, stale fingerprints, unsupported files, and paths that leave the configured image directory.
Relative signature validation allows the generated URLs to pass through a proxy or CDN host without weakening path validation. Public URLs are absolute so they can be used for Open Graph metadata.
Derivatives are generated proportionally on their first request, retain the source format, and are cached beneath images.cache_path. A per-variant file lock and atomic rename protect concurrent first requests. The source fingerprint changes when the source contents change, giving new URLs and making the default one-year immutable cache header safe. Old derivative directories may be removed during normal deployment cleanup.
When images.serve is false, Blogframe registers no image route and generates no srcset. Set images.base_url to the public directory or CDN containing the same relative image paths:
'images' => [ 'serve' => false, 'base_url' => 'https://cdn.example.com/blog', // ... ],
Exceptions
ArticleNotFoundExceptionis thrown byfindOrFail().InvalidArticleExceptiondescribes invalid article files or rendering failures.InvalidImageExceptiondescribes unsafe, missing, or unsupported images.InvalidConfigurationExceptiondescribes inconsistent package configuration.
The signed image route intentionally translates invalid image requests to HTTP 404 responses; Laravel's signature middleware returns HTTP 403 before image processing when a signature is missing or modified.
Testing
composer test
The test suite uses Orchestra Testbench and covers article discovery, translations, runtime tag replacement, responsive rendering, derivative caching, source invalidation, and signed-route security.
License
Blogframe is open-source software licensed under the MIT license.