I recently built my own CMS, Ava CMS, because no existing tool quite fit how I like to build personal sites.
Ava is a file-based CMS written in PHP. Content lives as Markdown files with YAML front matter, themes are plain PHP/HTML, and it's designed to keep hosting and workflow simple.
Docs: https://ava.addy.zone/docs
What I was tired of
Over the years I kept bumping into the same stuff when building personal sites:
- Templating engines that add a new language for no real gain
- Build steps and deploy pipelines for simple content edits
- "Just use a third-party service" for basic things like search
- Heavy admin UIs that try to do everything
- A lot of moving parts for what should be a small, maintainable site
I wanted something file-first, stays out of my way, and still lets me build dynamic features when I need them.
The middle ground nobody talks about
When you want to build a website, you usually hear about two paths:
Static site generators (Hugo, Jekyll, Eleventy). You write Markdown, run a build command, and get HTML files. They are fast to serve and easy to host, but everything runs through that build step. Want to add search? That's a third-party service. Want a contact form? Another service. Want to preview a draft? Rebuild first.
Database CMSs (WordPress, Drupal, Ghost). Full-featured and dynamic, but now you need a database server, backups, migrations, and often a whole ecosystem of plugins just to get started. The content lives in database rows you can't easily read or version.
There's a third option that doesn't get enough attention: file-based dynamic CMSs. Your content stays as readable files on disk (Markdown, YAML), but you still get dynamic features like search, filtering, taxonomies, and custom fields, without a database and without a build step.
This is the approach I wanted to explore with Ava.
Why this approach clicks (especially if you're learning)
If you're newer to web development, here's why file-based might be worth considering:
You can actually see what's happening. Your content isn't hidden in a database. It's just files. Open them in any text editor, read them, move them around, back them up by copying a folder. There's no magic layer between you and your data.
You learn real fundamentals. With Ava, themes are just HTML files with some PHP sprinkled in. No custom templating language to learn first, just the stuff that works everywhere:
<h1><?= $content->title() ?></h1>
<article><?= $ava->body($content) ?></article>
That <?= ?> is standard PHP. If you learn it here, you can use it in any PHP project. You're not learning "Ava syntax". You're learning PHP.
The feedback loop is instant. Edit a file, save it, refresh your browser. No waiting for builds, no deploy pipelines, no "clearing the cache and rebuilding". This matters more than people realise when you're learning. The faster you see results, the faster you understand what you're doing.
You can use your own tools. Write in VS Code, Obsidian, Typora, or a notes app. Manage your content with Git if you want version history. Upload via FTP if that's what you know. The CMS doesn't force a workflow on you.
How it works in practice
Let me walk you through what happens when you use a file-based approach like this.
Your content is just files you can read
A blog post looks like this on disk:
---
title: "My First Post"
slug: my-first-post
status: published
date: 2024-12-28
---
# Hello World
This is my first post. I can use **bold**, *italics*,
and [links](https://example.com).
The bit between the --- lines is YAML metadata (front matter). The rest is Markdown. Both are human-readable. You can open this in any text editor, on any computer, and understand what it says.
Compare that to digging through a WordPress database export to find your content. Or trying to move posts between static site generators that each have their own conventions.
Themes are HTML with helpers
There's no Blade, no Twig, no Liquid, no Handlebars. If you know HTML, you can start immediately:
<?php foreach ($ava->recent('post', 5) as $post): ?>
<article>
<h2><a href="<?= $ava->url('post', $post->slug()) ?>">
<?= $post->title() ?>
</a></h2>
<time><?= $ava->date($post->date()) ?></time>
</article>
<?php endforeach; ?>
This is a list of recent posts. It's verbose compared to Twig, sure. But it's also completely standard PHP that any developer can read. You're not locked into framework-specific knowledge that becomes useless if you move to a different tool.
Querying content
The $ava helper gives you a fluent query builder for filtering content. It reads like English:
// Get all published posts tagged "php", newest first
$posts = $ava->query()
->type('post')
->published()
->taxonomy('tag', 'php')
->orderBy('date', 'desc')
->limit(10)
->get();
// Search across all content
$results = $ava->query()
->published()
->search('flat file cms')
->get();
// Get a single item by slug
$about = $ava->query()->type('page')->slug('about')->first();
In practice, this queries the pre-built index, not the filesystem, so even complex queries stay fast.
Working with content items
Each content item is an object with methods for accessing its data:
$post = $ava->query()->type('post')->slug('my-post')->first();
$post->title(); // "My Post"
$post->slug(); // "my-post"
$post->date(); // DateTime object
$post->status(); // "published" or "draft"
$post->excerpt(); // Short description if set
$post->get('author'); // Any custom field from front matter
// Taxonomy terms
$post->terms('category'); // ['tutorials', 'php']
$post->hasTerm('tag', 'beginner'); // true/false
Nothing magical here. Just objects with readable methods. If you've used any PHP framework, this will feel familiar.
The indexing trick
"But wait," you might think, "if content is files, doesn't it have to read every file on every request? That sounds slow."
Good instinct. That's where indexing comes in.
When you run ./ava rebuild (or Ava auto-detects changes in development mode), it scans all your Markdown files once, extracts the metadata, and stores it in a binary index. Now when someone visits your homepage, Ava doesn't parse files. It reads from the pre-built index.
It's a bit like how a library works: you don't open every book to find what you need, you check the catalog first.
The index generates a few cache files optimised for different queries:
-
recent_cache.bin: The 200 most recent items per content type (homepage, RSS feeds) -
slug_lookup.bin: Fast single-item lookups by URL -
content_index.bin: Full metadata for complex queries and search -
routes.bin: URL routing map
Most traffic hits the first two (fast path). Search and deep pagination load the full index (still fast, just bigger).
The result: cached pages serve in about 0.02ms. Uncached pages render in about 5ms. For comparison, a basic WordPress page load is typically 200-500ms.
When files aren't enough
Most personal sites will happily run on files forever. But if you somehow end up with 10,000+ posts, Ava has a SQLite backend you can switch to with one config line:
// app/config/ava.php
'content_index' => [
'backend' => 'sqlite', // Switch from 'array' to 'sqlite'
],
Same content files, same workflow, but the index lives in a database instead of a binary file. SQLite uses way less memory per request, which matters when you have 50MB+ of metadata. You don't have to think about this until you need it.
Project layout (typical)
This is the kind of structure you'll see in an Ava project:
mysite/
├── app/
│ ├── config/
│ │ ├── ava.php # Site settings, caching, paths
│ │ ├── content_types.php # Define what you're publishing
│ │ └── taxonomies.php # Categories, tags, etc.
│ ├── plugins/ # Your custom plugins
│ ├── snippets/ # Reusable PHP components
│ └── themes/
│ └── default/
│ ├── templates/ # Page layouts (index.php, post.php)
│ ├── partials/ # Reusable fragments (header, footer)
│ ├── assets/ # CSS, JS, images
│ └── theme.php # Theme setup (register shortcodes, etc.)
├── content/
│ ├── pages/ # Your page .md files
│ ├── posts/ # Your post .md files
│ └── _taxonomies/ # Category/tag descriptions
├── public/
│ └── index.php # The single entry point
└── storage/
├── cache/ # Generated indexes and cached pages
└── logs/ # Error and indexer logs
The important bit is that your content and config stay transparent and versionable. No hidden .cache folders full of mystery files. No database dumps to manage. Just folders and text files.
What the files actually look like
Let me show you what some of these files contain in practice:
content/posts/hello-world.md: A blog post:
---
title: Hello World
slug: hello-world
status: published
date: 2024-12-28
author: Addy
category: tutorials
tags: [getting-started, php]
featured: true
---
This is my first post! Here's what I learned setting up Ava...
content/pages/about.md: A simple page:
---
title: About Me
slug: about
status: published
---
I'm a developer who likes building things with PHP.
## What I do
I build websites, mostly.
app/config/ava.php: Main site settings:
<?php
return [
'site' => [
'name' => 'My Blog',
'base_url' => 'https://addy.codes',
'timezone' => 'Europe/London',
'date_format' => 'F j, Y',
],
'theme' => 'default',
'content_index' => [
'mode' => 'auto', // Rebuild when files change
],
'webpage_cache' => [
'enabled' => true, // Cache rendered pages
],
];
app/themes/default/partials/header.php: A reusable header:
<header>
<a href="/" class="logo"><?= $site['name'] ?></a>
<nav>
<a href="/blog">Blog</a>
<a href="/about">About</a>
</nav>
</header>
app/themes/default/templates/index.php: The homepage template:
<!DOCTYPE html>
<html>
<head>
<title><?= $site['name'] ?></title>
<link rel="stylesheet" href="<?= $ava->asset('style.css') ?>">
</head>
<body>
<?= $ava->partial('header') ?>
<main>
<h1>Recent Posts</h1>
<?php foreach ($ava->recent('post', 10) as $post): ?>
<article>
<h2><a href="<?= $ava->url('post', $post->slug()) ?>">
<?= $post->title() ?>
</a></h2>
<time><?= $ava->date($post->date()) ?></time>
<p><?= $post->excerpt() ?></p>
</article>
<?php endforeach; ?>
</main>
<?= $ava->partial('footer') ?>
</body>
</html>
Every file is readable. You can understand what it does by opening it. That's the whole idea.
Configuration is PHP arrays
Instead of YAML or JSON config files, Ava uses plain PHP arrays. Here's what defining a content type looks like:
// app/config/content_types.php
return [
'post' => [
'label' => 'Blog Posts',
'content_dir' => 'posts',
'url' => ['type' => 'pattern', 'pattern' => '/blog/{slug}'],
'taxonomies' => ['category', 'tag'],
'fields' => [
'author' => ['type' => 'text', 'required' => true],
'featured' => ['type' => 'boolean', 'default' => false],
],
],
'project' => [
'label' => 'Projects',
'content_dir' => 'projects',
'url' => ['type' => 'pattern', 'pattern' => '/work/{slug}'],
],
];
Why PHP instead of YAML? You can add comments explaining why a setting exists. You can use constants, environment variables, or conditional logic. And PHP arrays are what the code uses anyway, so there is no extra parsing layer to debug.
Theme templates are just PHP files
A minimal theme template looks like this:
<!-- templates/post.php -->
<!DOCTYPE html>
<html>
<head>
<title><?= $content->title() ?> | <?= $site['name'] ?></title>
<link rel="stylesheet" href="<?= $ava->asset('style.css') ?>">
</head>
<body>
<?= $ava->partial('header') ?>
<main>
<article>
<h1><?= $content->title() ?></h1>
<time datetime="<?= $content->date()->format('Y-m-d') ?>">
<?= $ava->date($content->date()) ?>
</time>
<div class="content">
<?= $ava->body($content) ?>
</div>
<?php if ($content->terms('tag')): ?>
<div class="tags">
<?php foreach ($content->terms('tag') as $tag): ?>
<a href="<?= $ava->url('tag', $tag) ?>">#<?= $tag ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
</main>
<?= $ava->partial('footer') ?>
</body>
</html>
Partials (header.php, footer.php) are included with $ava->partial(). Assets get cache-busting URLs automatically. The $content variable is the current page/post, $site has your site config, and $ava is your helper for everything else.
Dynamic features without the usual hassle
Even though it's file-based, Ava aims to cover the stuff that normally pushes people toward a database-backed CMS:
- Custom content types: Not just posts and pages. Projects, recipes, events, whatever your site needs.
- Taxonomies: Categories, tags, or any custom grouping you want.
- Built-in search: Full-text search with relevance scoring. No Algolia account required.
- Shortcodes: Embed dynamic content or reusable components inside your Markdown.
- Plugins and hooks: Extend the system when you need to.
- Optional admin panel: A quick web UI for edits and uploads.
-
CLI: Run
./ava lintto check your content,./ava rebuildto refresh the index,./ava statusto see what's going on.
The goal is that you can keep the "edit files in your editor + Git" workflow, but still build the kinds of sites that usually need more infrastructure.
Shortcodes in action
Shortcodes let you embed dynamic content in Markdown without writing HTML:
Copyright © [year] [site_name]
Contact me at [email]hello@example.com[/email]
[snippet name="newsletter-signup"]
You can register your own shortcodes in your theme:
// app/themes/yourtheme/theme.php
return function ($app) {
$app->shortcodes()->register('youtube', function ($attrs) {
$id = $attrs['id'] ?? '';
return '<iframe src="https://youtube.com/embed/' . $id . '"
frameborder="0" allowfullscreen></iframe>';
});
};
Now [youtube id="dQw4w9WgXcQ"] in any Markdown file embeds a video.
The CLI
The command line is where Ava shines for developer workflows:
./ava status # Health check: PHP version, cache status, content stats
./ava lint # Validate all content files (catches YAML errors, missing fields)
./ava rebuild # Rebuild the content index
./ava make post "My New Post" # Scaffold a new content file
./ava cache:clear # Clear the page cache
./ava user:add me@example.com # Create an admin user
After a week or two, it becomes muscle memory. And if you're new to the terminal, Ava's CLI is a gentle introduction. The commands do one thing and the output is pretty helpful.
The honest trade-offs
This approach isn't for everyone. Here's where static site generators or database CMSs might still be better:
If you want zero server-side code, static site generators win. Your output is just HTML files that can live on a CDN with no PHP runtime.
If you need multiple editors with granular permissions, a database CMS is probably easier. File-based works great for solo developers or small teams who are comfortable with files, less so for clients who need a polished editing experience.
If you want a huge ecosystem of ready-made themes and plugins, WordPress is hard to beat. Ava is "bring your own code". That's a feature for some people and a dealbreaker for others.
Who this is for
Ava is for developers who:
- Prefer writing real HTML/CSS and sprinkling PHP when needed
- Want their site to be understandable end-to-end
- Like file-based workflows and Git history
- Want a small, boring hosting setup (any PHP host works)
It's not aiming at drag-and-drop site builders or big theme marketplaces. It's intentionally "bring your own code".
If that sounds appealing, or if you've been frustrated by the same things I was, this approach might be worth exploring, whether with Ava or another file-based CMS like Statamic, Grav, or Kirby.
If you want to poke at it
- Docs: https://ava.addy.zone/docs
- Home: https://ava.addy.zone/

Top comments (1)
If you build personal sites, what's your current setup? Static generator, database CMS, something else? I'd love to hear what you like about it, and what you wish was simpler! :)