DEV Community

Cover image for How I Built a Markdown-Powered CMS in Laravel With Zero Database
Michael Laweh
Michael Laweh

Posted on • Originally published at klytron.com

How I Built a Markdown-Powered CMS in Laravel With Zero Database

Every senior developer knows the allure of a shiny new framework. For me, that's been Laravel for over a decade. But when it came to building my personal site, klytron.com, I faced a familiar dilemma: how to manage content. The easy path is a database-backed CMS, but I wanted something blazing-fast, effortlessly maintainable, and deeply integrated with a modern PHP framework without succumbing to a "monolith" or sacrificing dynamic server-side capabilities for a static generator.

This article details the complete technical architecture of how I built a robust, Markdown-powered content management system within Laravel, achieving all these goals with a surprising twist: zero database for content.

The Rationale: Why I Opted Out of a Database for Content

As a seasoned IT Consultant, my default reflex for any content-driven application is to sketch out a database schema. For klytron.com, I did exactly that. But then I paused and asked a more fundamental question: do I genuinely need a database for this content?

For a personal portfolio and blog, the write path (publishing new articles) is incredibly light – perhaps a few posts a week at most. The read path, however, is paramount. Visitors expect instant access to information. In such a scenario, a flat-file system, combined with an intelligent caching strategy, offers compelling advantages that often outweigh the perceived benefits of a relational database:

  • Zero Schema Migrations on Deployment: Every git push triggers a deployment, and with a flat-file system, there are no php artisan migrate commands to worry about. Content is part of the codebase, handled seamlessly by tools like Deployer.
  • Content is Version-Controlled by Default: This is a massive win. Every single content edit, from a typo correction to a major rewrite, is a commit in Git. This provides a full diff history for free, allowing instant rollback and auditing.
  • Enhanced Security Posture: Eliminating the database for content means one less attack vector. There are no database credentials in the production environment's configuration, reducing the surface area for potential exploits.
  • Instant Local Development Setup: Clone the repository, run composer install, and npm install, and you're ready. No php artisan migrate --seed required, making onboarding for collaborators (or your future self) incredibly simple.
  • Unmatched Hosting Flexibility: The site runs efficiently on virtually any PHP host, requiring no managed database add-on. This simplifies infrastructure and potentially reduces costs.

The primary trade-off, of course, is query flexibility. You can't run complex SQL queries against your content. But for a content structure that largely maps to file system directories (e.g., blog/, portfolio/), you rarely need complex WHERE clauses or JOIN operations. Filtering a Laravel Collection in memory, once the content is loaded, is often more than sufficient.

The Core Architecture: Flat Files + ContentService + Cache

The system is designed with a clear, three-tiered approach to ensure both performance and maintainability:

resources/content/ ← Source of truth (Markdown files)

ContentService ← Parses, validates, transforms


Laravel File Cache (1h TTL) ← Serves all requests

At the heart of this architecture are simple Markdown files. Each content entry is a .md file, incorporating YAML frontmatter at the top for metadata:

yaml

title: 'Post Title'
slug: my-post-slug
category: 'Laravel'
tags:

  • laravel
  • php status: published published_at: '2026-01-15 09:00:00' author: 'Michael K. Laweh' read_time: 8 min read ---

Markdown content starts here

This structured approach leverages existing, battle-tested libraries. spatie/yaml-front-matter efficiently parses the metadata, while league/commonmark (with the GitHub-Flavoured Markdown extension) renders the body content. This instantly gives me robust support for features like fenced code blocks, tables, task lists, and strikethrough without any custom parsing logic.

ContentService — The Engine of Content Delivery

The ContentService is arguably the most crucial component of this architecture. Registered as a singleton in AppServiceProvider, it acts as the centralized gateway for all content loading, parsing, and caching logic.

php
block(2, function () use ($cacheKey, $path) {
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($path) {
return $this->parse($path);
});
});
}

public function all(string $directory): Collection
{
    $cacheKey = 'content.dir.' . str_replace('/', '.', $directory);

    return Cache::remember($cacheKey, $this->cacheTtl, function () use ($directory) {
        $path = $this->contentPath . '/' . $directory;

        return collect(glob($path . '/*.md'))
            ->map(fn (string $file) => $this->parse(
                $directory . '/' . basename($file, '.md')
            ))
            ->filter(fn (array $item) => ($item['status'] ?? '') === 'published')
            ->sortByDesc('published_at')
            ->values();
    });
}

private function parse(string $path): array
{
    $fullPath = $this->contentPath . '/' . $path . '.md';

    abort_unless(file_exists($fullPath), 404);

    $document = YamlFrontMatter::parseFile($fullPath);

    return array_merge($document->matter(), [
        'content' => $this->converter->convert($document->body())->getContent(),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

}

Let's dissect some key methods:

  • get(string $path): This method retrieves a single content item. It constructs a unique cache key based on the content path and then uses a powerful atomic lock pattern to ensure cache integrity and performance.
  • all(string $directory): This method retrieves all content items within a specified directory. It iterates through all .md files, parses them, filters for published items, sorts them by published_at date, and returns them as a Laravel Collection. This collection-based approach makes further filtering and manipulation incredibly intuitive.
  • parse(string $path): This private helper method handles the heavy lifting of reading the Markdown file, parsing its YAML frontmatter, and converting the Markdown body into HTML using league/commonmark. It also includes a file_exists check to gracefully handle missing content by throwing a 404.

The Atomic Lock Pattern: Preventing Cache Stampedes

The Cache::lock() call within the get() method is not just a nice-to-have; it's absolutely critical for high-traffic scenarios.

php
Cache::lock($cacheKey . '.lock', 5)
->block(2, function () use ($cacheKey, $path) {
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($path) {
return $this->parse($path);
});
});

Without this pattern, imagine a scenario where your cache expires, and suddenly, hundreds or thousands of concurrent requests hit the server. Each request would attempt to read the same Markdown file from disk and then write the cache entry simultaneously. This phenomenon, known as a cache stampede or thundering herd problem, would overwhelm your disk I/O, negating the benefits of caching and potentially bringing your server to its knees.

With Cache::lock():

  1. The first incoming request successfully acquires the lock.
  2. It then proceeds to read the Markdown file, parse it, and populate the cache.
  3. Any subsequent concurrent requests attempting to access the same content will block for up to 2 seconds (as specified by the block(2, ...) parameter) until the lock is released.
  4. Once the lock is released, these blocked requests find a warm cache entry, allowing them to proceed without re-parsing the file.

This ensures that only one request at a time performs the expensive file parsing and cache writing operation, preventing resource exhaustion and maintaining performance even under sudden load spikes.

Routing & Controllers: A Clean Separation of Concerns

Laravel's routing and controller layers remain blissfully unaware of the underlying content storage mechanism. This is a testament to strong architectural design – the ContentService completely abstracts away the flat-file implementation.

The routes are declarative and straightforward, mapping content types to dedicated controllers:

php
// routes/web.php

Route::get('/blog', [BlogController::class, 'index'])->name('blog.index');
Route::get('/blog/{slug}', [BlogController::class, 'show'])->name('blog.show');
Route::get('/blog/category/{category}', [BlogController::class, 'category'])->name('blog.category');

Route::get('/portfolio', [ProjectController::class, 'index'])->name('portfolio.index');
Route::get('/portfolio/{slug}', [ProjectController::class, 'show'])->name('portfolio.show');

Controllers are kept intentionally thin, adhering to the "fat model, thin controller" principle (or in this case, "fat service, thin controller"). They delegate all content retrieval responsibilities directly to the injected ContentServiceInterface:

php
public function show(string $slug, ContentServiceInterface $contentService): View
{
$post = $contentService->get("blog/{$slug}");

$relatedPosts = $contentService->all('blog')
    ->where('category', $post['category'])
    ->where('slug', '!=', $slug)
    ->take(3);

return view('blog.show', compact('post', 'relatedPosts'));
Enter fullscreen mode Exit fullscreen mode

}

As you can see, retrieving content is as simple as calling $contentService->get() or $contentService->all(). The controller then performs any necessary business logic (like finding related posts by category) using standard Laravel Collection methods, and passes the data to the view.

Discovery & SEO: Beyond the Content

One might assume that ditching a database complicates features like RSS feeds or XML sitemaps. Quite the opposite, in fact. Because ContentService::all() consistently returns a sorted Laravel Collection of all published content, generating these discovery mechanisms becomes a trivial exercise in collection transformation. No ORM, no complex query builder, and no N+1 query paranoia.

RSS / Atom / JSON Feeds

I provide three popular feed formats: Atom (/feed/posts), RSS 2.0 (/feed/posts/rss), and JSON Feed 1.1 (/feed/posts/json). The FeedController simply fetches the latest posts and renders them into the appropriate XML or JSON view:

php
// Excerpt from FeedController::rss()
$posts = $this->contentService->all('blog')->take(20);

return response(
view('feeds.rss', compact('posts'))->render(),
200,
['Content-Type' => 'application/rss+xml; charset=UTF-8']
);

Auto-discovery tags are strategically placed in the site's section, allowing modern feed readers and browsers to easily detect and subscribe to the feeds without any manual configuration.

XML Sitemap

Similarly, the XML sitemap dynamically includes all published blog posts, portfolio items, and service pages. It's always perfectly in sync with the resources/content/ directory, eliminating the need for a separate sitemap_entries table or manual updates.

php
// Excerpt from SitemapController::index()
$posts = $this->contentService->all('blog');
$projects = $this->contentService->all('portfolio');
$services = $this->contentService->all('services');

return response(
view('sitemap.index', compact('posts', 'projects', 'services'))->render(),
200,
['Content-Type' => 'application/xml']
);

Advanced SEO: Schema.org & Open Graph

For maximum discoverability and rich snippets in search results, a dedicated SeoService generates structured data on every page request. This service injects page-type-specific JSON-LD schemas directly into the HTML. For a blog post, for example, it might look like this:

// For a blog post
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Post Title",
"author": { "@type": "Person", "name": "Michael K. Laweh" },
"datePublished": "2026-01-15",
"image": "https://klytron.com/assets/images/blog/hero.png"
}

Furthermore, for compelling social media sharing, dynamic Open Graph images are generated on-the-fly via an OgImageController. This controller uses PHP's GD library to render custom PNG images with a branded background, the post title, and the site domain – all server-rendered, completely free of charge, and without relying on any third-party image service or API keys.

Deployment: Zero-Downtime Atomic Releases

Content updates and code changes are deployed with a single git push command, just like any standard Laravel application. I use Deployer, a fantastic PHP deployment tool, to handle atomic, zero-downtime releases:

bash

My deploy command (via php-deployment-kit)

dep deploy production

What Deployer does:

1. Clone latest commit into releases/YYYYMMDDHHII/

2. composer install --no-dev --optimize-autoloader

3. npm run build (Vite)

4. php artisan config:cache

5. php artisan route:cache

6. php artisan view:cache

7. ln -sfn releases/YYYYMMDDHHII current ← atomic swap

8. php artisan cache:clear

9. Clean up old releases (keep last 5)

The magic happens at Step 7: ln -sfn releases/YYYYMMDDHHII current. This command performs an atomic symlink swap at the kernel level. This means the web server (Nginx, in my case) instantly switches from pointing to the old current release directory to the new one in a single, indivisible operation. There is no window of time where the server is pointing at a broken, partial, or inconsistent build, ensuring a truly zero-downtime deployment. Crucially, the final php artisan cache:clear ensures any stale content cache entries are purged, forcing the ContentService to re-read and re-cache content from the newly deployed Markdown files.

Lessons Learned & Future Enhancements

While this flat-file CMS has proven incredibly effective, no system is perfect. Here are a few areas I'd consider enhancing or doing differently in a future iteration:

  1. Content Watching for Local Development: Currently, editing a Markdown file in my local development environment requires a manual php artisan cache:clear to see the changes. A simple file watcher (e.g., using inotifywait or integrating with Vite's HMR) could automatically send a cache-clear signal, drastically improving the developer experience.
  2. Lightweight CLI for Content Management: Manually copying frontmatter boilerplate for new blog posts can be tedious. A simple php artisan content:new blog "My Post Title" command could streamline content creation, generating a pre-filled Markdown file with the correct structure.
  3. Scalable Search with a Flat Index: The current site search works by filtering cached collections in PHP, which is perfectly adequate for the current volume of content. However, as the number of articles grows into the hundreds, a pre-built Fuse.js JSON index (for client-side search) or a lightweight, self-hosted MeiliSearch instance (for server-side search) would offer a far more scalable and performant search experience.

The Takeaway: When to Embrace the Flat File

A traditional database is an indispensable tool for a vast array of problems, particularly those involving complex relationships, frequent write operations, or dynamic, user-generated content. However, for content-heavy but write-light applications like a personal portfolio or blog, a database can often introduce unnecessary complexity and overhead.

The flat-file CMS architecture I've described here offers a compelling alternative:

  • It provides content version control for free, leveraging the power of Git.
  • It completely eliminates the burden of database migrations on deployments.
  • It makes local development setup instantaneous.
  • And, when coupled with aggressive, atomically-locked caching, it can outperform most traditional database-backed CMSes in terms of read speed and resource utilization.

The ContentService and associated patterns detailed in this article are not theoretical – they are the exact, battle-tested code running klytron.com today. If you're a Laravel developer looking to build a blazing-fast content site, this architecture is straightforward enough to adapt and implement in your own projects within an afternoon. The core principles – atomic locking, collection-based filtering, and type-checked frontmatter – are robust and widely applicable.

If you're curious about diving deeper into any specific layer – perhaps the intricacies of feed generation, the full Schema.org implementation, or the complete Deployer pipeline setup – feel free to reach out.

👉 Read the complete deep-dive with the full code repository and bonus security checklist on klytron.com

Top comments (0)