/ Content
I was recently tasked with implementing a very simple blog on our marketing site, which is a basic Laravel site with several pages, all using the default template and layout. When coming up with a solution, my biggest priority was building something I didn't have to manage. The big players (WordPress and Statamic) were obvious solutions, but both were overkill for something that was going to be super simple and a way to boost our SEO. If it was one post a week, maybe it would be easier to just take the document, format it, add the routes, update the blog index with the new blog post, and push it all live. That would take maybe 30 minutes a week, but that was still too much, and I wanted a way to automate it. The first thing I wanted to resolve: I didn't want to touch routes, and I didn't want to touch the blog post index page. If I could automate that, then I just needed to format a single document and push it to production. I remembered Laravel had a package called Folio which I could use to solve this, and it became another tool in my arsenal.
What is Folio
Folio brings file-based routing to Laravel. If you've used Next.js, the concept is the same, but for Blade instead of React. The file path is the URL path.
Install it with:
composer require laravel/folio
php artisan folio:install
That registers the service provider. By default it watches resources/views/pages, but you configure the base directory yourself. For a blog at /blog, I register a separate Folio path in AppServiceProvider:
use Laravel\Folio\Folio;
public function boot(): void
{
Folio::path(resource_path('views/pages'))->uri('/');
Folio::path(resource_path('views/blog-posts'))->uri('/blog');
}
Now anything in resources/views/blog-posts is accessible under /blog.
Basic Usage
A file at resources/views/blog-posts/email-open-rates.blade.php is a route at /blog/email-open-rates. No routes/web.php entry, no controller. The file is the route.
Folio also supports dynamic segments. A file named [slug].blade.php catches any path and injects the $slug variable into the template:
{{-- resources/views/blog-posts/[slug].blade.php --}}
<?php
$post = Post::where('slug', $slug)->firstOrFail();
?>
<h1>{{ $post->title }}</h1>
For a simple static blog I skip dynamic routing entirely. Each post gets its own dedicated blade file with a fixed filename. URLs are predictable, files are easy to find, and there's no database query on every page load.
Index Files
A file named index.blade.php maps to the root of its directory. So resources/views/blog-posts/index.blade.php is the /blog listing page.
Folio is smart enough to not create a /blog/index route from it. The index.blade.php is treated as the directory root, same as index.html in a web server.
Every other blade file in the same directory becomes a /blog/{filename} route. The index and the posts coexist in the same folder without any collision.
Route Names
You can name routes directly inside the blade file using a PHP block at the top:
<?php
use function Laravel\Folio\name;
name('blog.index');
?>
<x-layout>
...
</x-layout>
That PHP block is executed by Folio during route registration. Once named, you can use route('blog.index') anywhere in the app, just like a regular named route in web.php.
Individual posts can be named the same way:
<?php
use function Laravel\Folio\name;
name('blog.post.email-open-rates');
?>
I name the index but not individual posts. The slugs are stable enough that I just reference URLs directly, and maintaining a unique name for every post file is more ceremony than it's worth.
Auto-Populating the Index
This is the piece I like most about this setup. Instead of maintaining a separate list of posts in a database or config file, I scan the directory at render time and build the listing from the files themselves.
The index file reads every blade file in the directory, extracts a metadata block from each one, and assembles a sorted list:
@php
$posts = collect(\Illuminate\Support\Facades\File::files(resource_path('views/blog-posts')))
->filter(fn($file) =>
$file->getFilename() !== 'index.blade.php' &&
str_ends_with($file->getFilename(), '.blade.php')
)
->map(function ($file) {
$content = File::get($file->getPathname());
$slug = str_replace('.blade.php', '', $file->getFilename());
if (! preg_match('/\{\{--\s*@post-meta\s*(.*?)--\}\}/s', $content, $match)) {
return null;
}
$meta = [];
foreach (explode("\n", trim($match[1])) as $line) {
if (str_contains($line, ':')) {
[$key, $value] = explode(':', $line, 2);
$meta[trim($key)] = trim($value);
}
}
return [
'slug' => $slug,
'title' => $meta['title'] ?? '',
'description' => $meta['description'] ?? '',
'date' => $meta['date'] ?? '2026-01-01',
'read_time' => $meta['read_time'] ?? '10',
'url' => '/blog/' . $slug,
];
})
->filter()
->filter(fn($post) => $post['date'] <= now()->toDateString())
->sortByDesc('date')
->values();
@endphp
Two things worth calling attention to here. First, the regex: it's looking for a {{-- @post-meta ... --}} Blade comment block and parsing the lines inside as key-value pairs. Second, the date filter: posts with a future date are excluded. That's your publish scheduling, completely free, no cron job required.
Add a new post blade file and it appears in the listing. Delete a file and it's gone. No database, no admin panel, no sync step.
Post Metadata in a Blade Comment
Each post file includes this block:
{{--
@post-meta
title: Why Your Email Open Rates Are Dropping
description: Your list is silently decaying. Here is what that costs your deliverability.
date: 2026-06-01
read_time: 6
--}}
A Blade comment is invisible to the rendered HTML. It's not a custom directive, not a config file. It's just a structured comment that the index scanner knows how to read.
The parser is intentionally minimal. Split on newlines, split each line on :, trim the keys and values. No YAML library, no frontmatter package. If the block doesn't exist in a file, that file gets filtered out of the listing, which is useful when you want a post blade file to exist for preview purposes but not show up publicly yet.
SEO Tags
Folio gives you file-based routing. It doesn't do anything for SEO on its own. Each post needs its own canonical URL, Open Graph tags, and structured data.
I handle this with a @section('meta') block that the layout component outputs in <head>:
@section('meta')
<link rel="canonical" href="https://example.com/blog/email-open-rates">
<meta name="description" content="Your list is silently decaying...">
<meta property="og:title" content="Why Your Email Open Rates Are Dropping">
<meta property="og:description" content="Your list is silently decaying...">
<meta property="og:image" content="https://example.com/images/og-image.png">
<meta property="og:url" content="https://example.com/blog/email-open-rates">
<meta property="og:type" content="article">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Why Your Email Open Rates Are Dropping">
<meta name="twitter:description" content="Your list is silently decaying...">
<meta name="twitter:image" content="https://example.com/images/og-image.png">
@php
$schema = [
'@context' => 'https://schema.org',
'@type' => 'BlogPosting',
'headline' => 'Why Your Email Open Rates Are Dropping',
'description' => 'Your list is silently decaying every single day.',
'datePublished' => '2026-06-01',
'dateModified' => '2026-06-01',
'author' => [
'@type' => 'Organization',
'name' => 'My Site',
],
'mainEntityOfPage' => [
'@type' => 'WebPage',
'@id' => 'https://example.com/blog/email-open-rates',
],
];
@endphp
<script type="application/ld+json">
{!! json_encode($schema, JSON_UNESCAPED_SLASHES) !!}
</script>
@endsection
Yes, it's verbose. But each field is genuinely different per post, so there's no way to meaningfully abstract it without just hiding the repetition. The BlogPosting JSON-LD schema is what Google uses to generate rich snippets, so it's worth including even though it's not strictly required. The og:type of article (vs website) is what social scrapers use to decide how to render the preview.
For the index page, use ItemList schema instead of BlogPosting:
$blogListSchema = [
'@context' => 'https://schema.org',
'@type' => 'ItemList',
'name' => 'Blog',
'itemListElement' => $posts->map(fn($post, $i) => [
'@type' => 'ListItem',
'position' => $i + 1,
'item' => [
'@type' => 'BlogPosting',
'name' => $post['title'],
'url' => 'https://example.com' . $post['url'],
'description' => $post['description'],
],
])->values()->all(),
];
The index schema is generated dynamically from the $posts collection you already built, so it stays in sync with what's on the page without any extra work.
Deploying to Production
Everything works fine locally. Then you deploy, hit a page, and get a 500. Running php artisan folio:list on the server shows no routes at all.
This happened to me. The cause was route caching. When php artisan route:cache runs as part of your deploy, Laravel serializes all routes to a bootstrap cache file. Folio route registration that happens inside a service provider's boot() method can end up not being included in that cache, depending on where it's registered and when.
When php artisan folio:install runs, it publishes a FolioServiceProvider to app/Providers/FolioServiceProvider.php. That's where your Folio::path() calls live by default. The problem is that this provider can run in the wrong order relative to route caching, leaving Folio's routes out of the cache entirely.
The fix is to move the registration into AppServiceProvider:
// app/Providers/AppServiceProvider.php
use Laravel\Folio\Folio;
public function boot(): void
{
Folio::path(resource_path('views/pages'))->uri('/');
Folio::path(resource_path('views/blog-posts'))->uri('/blog');
}
And remove the published FolioServiceProvider entirely, or at least clear out the boot() method so it's not registering paths twice.
After moving it, clear the route cache and rebuild:
php artisan route:clear
php artisan route:cache
Then verify Folio can see the routes:
php artisan folio:list
If it returns your pages, you're good. If it's still empty after clearing the cache, double-check that AppServiceProvider is listed in bootstrap/providers.php and that FolioServiceProvider isn't also registering the same paths.
This is probably the most frustrating part of the Folio setup because it works perfectly in local development where route caching isn't enabled. You only hit it the first time you deploy.
Putting It Together
The full setup is:
- Install Folio and register your base directories
- Create
index.blade.phpthat scans for files and extracts@post-metablocks - Each post blade file includes a
@post-metacomment block and its own SEO@section('meta') - Write the post content as regular Blade HTML
New posts are a single file. No migrations, no models, no database entries. The index updates itself. Scheduling is just a future date in the metadata block.
I've found this particularly clean for marketing sites where a developer is the one publishing content anyway. If you need a non-technical content author with an actual UI, you'd want a CMS instead. But for a dev-maintained blog this avoids a lot of infrastructure.
In the next post, I'll show how I automated this entire flow using a GitHub issue template and Copilot. Fill out a form in GitHub, Copilot creates the blade file with all the SEO tags and correct structure, and opens a PR. You just review and merge.