For raw Markdown (ideal for AI context): View on GitHub
Overview
Full docs: https://ava.addy.zone/docs
Ava CMS is a flat-file PHP CMS (PHP 8.3+) requiring no database. Content is Markdown with YAML frontmatter. Configuration is PHP arrays. No build step—edit, refresh, done.
This project is under very early, active development and may contain bugs or security issues. It is likely not ready for production websites.
You are responsible for reviewing, testing, and securing any deployment. Ava CMS is provided as free, open-source software without warranty (GNU General Public License), see LICENSE.
Installation: Always download from GitHub Releases—never clone the repository directly. The main branch may contain incomplete or untested work.
Core Philosophy:
- Files are the source of truth (content, config, themes)
- No WYSIWYG—users write Markdown in their preferred editor
- No database required (SQLite optional for 10k+ items)
- Immediate publishing—no build/deploy step
- Bespoke by design—any content type without plugins
Project Structure:
mysite/
├── app/ # Your code
│ ├── config/ # Configuration (ava.php, content_types.php, taxonomies.php)
│ ├── plugins/ # Plugin folders
│ ├── snippets/ # PHP snippets for shortcode
│ └── themes/{name}/ # Theme templates, assets, partials
├── content/ # Markdown content files
│ ├── pages/ # Hierarchical pages
│ ├── posts/ # Blog posts
│ └── _taxonomies/ # Term registries
├── public/ # Web root (index.php, media/, assets/)
├── storage/cache/ # Index and page cache
└── ava # CLI tool
Requirements: PHP 8.3+, extensions: mbstring, json, ctype. Optional: pdo_sqlite, igbinary, opcache, imagick/gd (media uploads).
Configuration
Full docs: https://ava.addy.zone/docs/configuration
All settings in app/config/ as PHP arrays. Three main files:
ava.php— Main settings (site, paths, cache, themes, plugins, security, debug)content_types.php— Content type definitionstaxonomies.php— Taxonomy definitions
Main Settings (ava.php)
Site Identity:
'site' => [
'name' => 'My Site',
'base_url' => 'https://example.com', // No trailing slash
'timezone' => 'Europe/London',
'locale' => 'en_GB',
'date_format' => 'F j, Y',
],
Paths:
'paths' => [
'content' => 'content',
'themes' => 'app/themes',
'plugins' => 'app/plugins',
'snippets' => 'app/snippets',
'storage' => 'storage',
'aliases' => [
'/media/' => '/media/',
],
],
Content Index (performance-critical):
'content_index' => [
'mode' => 'auto', // 'auto' (dev), 'never' (prod), 'always' (debug)
'backend' => 'array', // 'array' or 'sqlite' (for 10k+ items)
'use_igbinary' => true,
'prerender_html' => false, // Optional: pre-render Markdown → HTML during rebuild
],
Webpage Cache:
'webpage_cache' => [
'enabled' => true,
'ttl' => null, // null = until rebuild
'exclude' => ['/api/*'],
],
Content Parsing:
'content' => [
'markdown' => [
'allow_html' => true,
'heading_ids' => true,
'disallowed_tags' => ['script', 'noscript'],
],
'id' => ['type' => 'ulid'], // 'ulid' or 'uuid7'
],
Security:
'security' => [
'shortcodes' => ['allow_php_snippets' => true],
'preview_token' => 'secure-random-token',
],
Plugins: 'plugins' => ['sitemap', 'feed', 'redirects'],
Debug:
'debug' => [
'enabled' => false,
'display_errors' => false, // Never true in production
'log_errors' => true,
'level' => 'errors', // 'all', 'errors', 'none'
],
Content Types (content_types.php)
return [
'post' => [
'label' => 'Posts',
'content_dir' => 'posts',
'url' => [
'type' => 'pattern',
'pattern' => '/blog/{slug}',
'archive' => '/blog',
],
'templates' => [
'single' => 'post.php',
'archive' => 'archive.php',
],
'taxonomies' => ['category', 'tag'],
'sorting' => 'date_desc',
'cache_fields' => ['author', 'featured_image'],
'search' => [
'enabled' => true,
'fields' => ['title', 'excerpt', 'body'],
'weights' => [
'title_phrase' => 80,
'title_token' => 10,
'excerpt_phrase' => 30,
'body_phrase' => 20,
'body_token' => 2,
'featured' => 15,
],
],
],
];
Content Type Options:
| Option | Required | Description |
|---|---|---|
label |
Yes | Human-readable display name |
content_dir |
Yes | Folder inside content/ for this type |
url |
Yes | URL generation settings (see URL Types) |
templates |
Yes | Template file mappings (single, archive) |
taxonomies |
No | Which taxonomies apply. Default: [] |
sorting |
No | Default sort: date_desc, date_asc, title, manual |
cache_fields |
No | Extra frontmatter fields for archive cache |
search |
No | Search config (enabled, fields, weights) |
URL Types:
hierarchical— URLs mirror file paths.content/pages/about/team.md→/about/teampattern— Template-based. Placeholders:{slug},{id},{yyyy},{mm},{dd}
Taxonomies (taxonomies.php)
return [
'category' => [
'label' => 'Categories',
'hierarchical' => true,
'public' => true,
'rewrite' => ['base' => '/category'],
'behaviour' => ['allow_unknown_terms' => true, 'hierarchy_rollup' => true],
'ui' => ['show_counts' => true, 'sort_terms' => 'name_asc'],
],
];
| Option | Default | Description |
|---|---|---|
label |
Required | Human-readable name |
hierarchical |
false |
Support parent/child relationships |
public |
true |
Create public archive pages |
rewrite.base |
'/{taxonomy}' |
URL prefix for archives |
behaviour.allow_unknown_terms |
true |
Auto-create terms when used |
ui.sort_terms |
'name_asc' |
Sort: name_asc, name_desc, count_asc, count_desc |
Term registries: content/_taxonomies/{taxonomy}.yml — Pre-define terms with metadata.
Environment-Specific Config
$config = [...];
if (getenv('APP_ENV') === 'development') {
$config['content_index']['mode'] = 'auto';
$config['debug']['enabled'] = true;
}
return $config;
Content
Full docs: https://ava.addy.zone/docs/content
Content files are Markdown (.md) or HTML (.html) with YAML frontmatter. Located in content/ folder, organized by content type. File extension determines format: .md → Markdown parsing, .html → raw HTML (no Markdown parsing). Shortcodes and path aliases work in both formats.
File Structure
content/
├── pages/ # Hierarchical pages
│ ├── index.md # Homepage (/)
│ ├── about.md # /about
│ ├── custom.html # /custom (HTML format, no Markdown parsing)
│ └── docs/
│ └── index.md # /docs
├── posts/ # Pattern-based posts
│ └── hello.md # /blog/hello (if pattern is /blog/{slug})
└── _taxonomies/ # Term registries
└── category.yml
Frontmatter
---
id: 01JGMK0000POST0000000001
title: My Post
slug: my-post
status: published
date: 2024-12-28
updated: 2024-12-30
excerpt: Short summary for listings
template: custom.php
order: 10
category:
- tutorials
tag:
- php
meta_title: SEO Title
meta_description: SEO description
canonical: https://example.com/post
og_image: "/media/social.jpg"
noindex: false
cache: true
redirect_from:
- /old-url
assets:
css:
- "/media/css/custom.css"
js:
- "/media/js/script.js"
---
Core Fields:
title— Display title (defaults to slugified filename)slug— URL identifier. For hierarchical types, URL comes from file path, not slugstatus—draft,published,unlistedid— Optional ULID/UUID7 for stable referencesdate,updated— Timestampsexcerpt— Summary for listings/searchtemplate— Override default template
Taxonomy Fields:
category: tutorials # Single term
category: # Multiple terms
- tutorials
- php
tax: # Alternative grouped format
category: [tutorials, php]
tag: beginner
SEO Fields: meta_title, meta_description, canonical, noindex, og_image
Behavior Fields:
cache: false— Disable caching for this pageredirect_from: [/old-url]— 301 redirects from old URLsorder— Manual sort order (use withsorting: 'manual')
Content Format: Determined by file extension (.md = Markdown, .html = raw HTML). If both about.md and about.html exist, .html wins.
Custom Fields: Any field is accessible via $item->get('field_name').
Content Status
| Status | Behavior |
|---|---|
draft |
Not publicly routed. Viewable via preview token. |
published |
Public. In listings, archives, taxonomy indexes. |
unlisted |
Public via direct URL. Excluded from listings/archives/taxonomies. |
Path Aliases
Defined in ava.php, expanded at render time:
 → /media/photo.jpg
Creating Content
CLI: ./ava make post "Title" — Creates file with ULID, slug, date, draft status
Validation: ./ava lint — Checks YAML, required fields, duplicate slugs/IDs
CLI
Full docs: https://ava.addy.zone/docs/cli
Run from project root: ./ava <command> [options]
| Command | Description |
|---|---|
status |
Site overview and health check |
rebuild [--keep-webcache] |
Rebuild content index |
lint |
Validate all content files |
make <type> "Title" |
Create new content with scaffolding |
cache:stats |
Webpage cache statistics |
cache:clear [pattern] |
Clear cached pages |
logs:stats |
Log file statistics |
logs:tail [name] [-n N] |
Show last N lines of log |
update:check [--force] |
Check for updates (cached 1 hour) |
update:apply [--yes] [--dev] |
Download and apply update |
update:stale [--dev] [--clean] [--yes] |
Detect/remove stale files from older releases |
Plugin Commands: sitemap:stats, feed:stats, redirects:list, redirects:add <from> <to> [code], redirects:remove <from>
Theming
Full docs: https://ava.addy.zone/docs/theming
Themes are HTML + PHP templates. No build step, no custom templating language.
Structure
app/themes/mytheme/
├── templates/ # Page layouts
│ ├── index.php # Default fallback
│ ├── page.php # Pages
│ ├── post.php # Posts
│ ├── archive.php # Listings
│ ├── taxonomy.php # Term archives
│ └── 404.php # Not found
├── partials/ # Reusable fragments
├── assets/ # CSS, JS, images
└── theme.php # Bootstrap (optional)
Template Variables
| Variable | Type | Description |
|---|---|---|
$content |
Item |
Current content item (single pages) |
$query |
Query |
Query for archives/listings |
$tax |
array |
Taxonomy context (taxonomy pages) |
$site |
array |
Site config: name, url, timezone |
$request |
Request |
Current HTTP request |
$ava |
TemplateHelpers |
Helper methods |
Content Item ($content) Methods
$content->id() // ULID
$content->title() // Title
$content->slug() // URL slug
$content->type() // 'page', 'post', etc.
$content->status() // 'draft', 'published', 'unlisted'
$content->isPublished() // Status check helpers
$content->date() // DateTimeImmutable|null
$content->updated() // DateTimeImmutable|null (falls back to date)
$content->excerpt() // Excerpt string
$content->rawContent() // Raw body content
$content->format() // 'markdown' or 'html' (from file extension)
$content->isHtml() // true if .html file
$content->terms('category') // Taxonomy terms array
$content->get('field') // Custom frontmatter field
$content->get('field', 'default') // With default
$content->has('field') // Check field exists
$content->metaTitle() // SEO title
$content->metaDescription() // SEO description
$content->noindex() // Should search engines skip?
$content->frontmatter() // All frontmatter as array
Template Helpers ($ava) Methods
Rendering:
$ava->body($content) // Render content body (uses pre-render cache when enabled)
$ava->markdown($string) // Render Markdown string
$ava->partial('header', $data) // Include partial with data
URLs:
$ava->url('post', 'my-slug') // Content URL
$ava->termUrl('category', 'php') // Term archive URL
$ava->asset('style.css') // Theme asset with cache-bust
$ava->baseUrl() // Site base URL
Queries:
$ava->query() // New query builder
$ava->recent('post', 5) // Recent items shortcut
$ava->get('page', 'about') // Get specific item
$ava->terms('category') // All terms for taxonomy
Utilities:
$ava->date($date, 'F j, Y') // Format date
$ava->ago($date) // "2 days ago"
$ava->e($value) // HTML escape
$ava->metaTags($content) // Output SEO meta tags
$ava->itemAssets($content) // Output per-item CSS/JS
$ava->pagination($query, $path) // Render pagination
$ava->config('key.subkey') // Get config value
Query Builder
$posts = $ava->query()
->type('post')
->published()
->whereTax('category', 'tutorials')
->where('featured', true)
->orderBy('date', 'desc')
->perPage(10)
->page(1)
->search('query')
->get();
// Result methods
$query->count() // Total items
$query->totalPages() // Page count
$query->hasMore() // More pages?
$query->pagination() // Full pagination info
Where operators: =, !=, >, >=, <, <=, in, not_in, like
Template Resolution
- Frontmatter
template: landing→templates/landing.php - Content type's configured template
single.phpfallbackindex.phpfallback
Theme Bootstrap (theme.php)
<?php
return function (\Ava\Application $app): void {
// Register shortcodes
$app->shortcodes()->register('mycode', fn() => 'output');
// Add to template context
Hooks::addFilter('render.context', function ($ctx) {
$ctx['custom'] = 'value';
return $ctx;
});
// Custom routes
$app->router()->addRoute('/search', function ($request) use ($app) {
// ...
});
};
Routing
Full docs: https://ava.addy.zone/docs/routing
URLs are generated automatically from content structure and configuration. Routes are cached in binary files for instant lookups.
URL Types
Hierarchical — URLs mirror file paths:
'page' => [
'url' => ['type' => 'hierarchical', 'base' => '/'],
]
// content/pages/about/team.md → /about/team
// content/pages/index.md → /
Pattern — Template-based URLs:
'post' => [
'url' => [
'type' => 'pattern',
'pattern' => '/blog/{slug}',
'archive' => '/blog',
],
]
Placeholders: {slug}, {id}, {yyyy}, {mm}, {dd}
Route Matching Order
- Hook interception (
router.before_match) - Trailing slash redirect (301)
- Redirects from
redirect_fromfrontmatter - Custom routes (
$router->addRoute()) - Content routes (from cache)
- Preview mode (drafts with token)
- Prefix routes (
$router->addPrefixRoute()) - Taxonomy routes
- 404
Custom Routes
// In theme.php or plugin.php
$router->addRoute('/api/search', function ($request) use ($app) {
return \Ava\Http\Response::json(['results' => []]);
});
// With parameters
$router->addRoute('/api/posts/{id}', function ($request, $params) {
$id = $params['id'];
// ...
});
// Prefix routes (matches /api/*)
$router->addPrefixRoute('/api/', function ($request) {
// ...
});
Taxonomy Routes
When public: true in taxonomy config:
/category→taxonomy-index.php/category/tutorials→taxonomy.php
Preview Mode
Access drafts: ?preview=1&token=YOUR_TOKEN
Configure: 'security' => ['preview_token' => 'random-token']
Generating URLs
$ava->url('post', 'my-slug') // /blog/my-slug
$ava->url('page', 'about/team') // /about/team (hierarchical path)
$ava->termUrl('category', 'php') // /category/php
$ava->fullUrl('/about') // https://example.com/about
Shortcodes
Full docs: https://ava.addy.zone/docs/shortcodes
Dynamic content in Markdown via [tag] syntax. Processed after Markdown conversion.
Built-in Shortcodes
| Shortcode | Output |
|---|---|
2026 |
Current year |
2026-04-11 |
Formatted current date |
Ava CMS |
Site name from config |
https://ava.addy.zone |
Site URL from config |
you@example.com |
Obfuscated mailto link |
|
Renders app/snippets/file.php |
Creating Shortcodes
// In theme.php
$app->shortcodes()->register('greeting', function ($attrs, $content, $tag) {
$name = $attrs['name'] ?? 'friend';
return "Hello, " . htmlspecialchars($name) . "!";
});
Callback parameters:
$attrs— Array of attributes$content— Content between tags (null if self-closing)$tag— Shortcode name (lowercase)
Snippets
PHP files in app/snippets/ folder, invoked via .
Variables available:
$params— Attributes array$content— Content between tags$ava— Rendering engine$app— Application instance
<?php // app/snippets/cta.php ?>
<?php $heading = $params['heading'] ?? 'Ready?'; ?>
<div class="cta">
<h3><?= htmlspecialchars($heading) ?></h3>
<?= $content ?>
</div>
Security: Snippet names can't contain .. or /. Disable with security.shortcodes.allow_php_snippets = false.
Limitations
- No nested shortcodes
- Paired content stops at next
[character
Plugins
Full docs: https://ava.addy.zone/docs/creating-plugins
Reusable extensions in app/plugins/{name}/plugin.php. Survive theme changes.
<?php
return [
'name' => 'My Plugin',
'version' => '1.0.0',
'boot' => function($app) {
// Routes, hooks, etc.
},
'commands' => [
['name' => 'myplugin:task', 'description' => 'Do something',
'handler' => function($args, $cli, $app) { return 0; }],
],
];
Enable in ava.php: 'plugins' => ['sitemap', 'feed', 'my-plugin']
Hooks
Filters — Modify and return data:
use Ava\Plugins\Hooks;
Hooks::addFilter('render.context', fn($ctx) => [...$ctx, 'custom' => 'value'], priority: 10);
Actions — React to events:
Hooks::addAction('indexer.rebuild', function($app) { /* sync content */ });
| Hook | Type | Description |
|---|---|---|
router.before_match |
Filter | Intercept routing |
content.loaded |
Filter | Modify loaded content item |
render.context |
Filter | Add template variables |
render.output |
Filter | Modify final HTML |
markdown.configure |
Action | Configure CommonMark |
indexer.rebuild |
Action | After content index rebuild |
cli.rebuild |
Action | After CLI rebuild command |
Bundled Plugins
Full docs: https://ava.addy.zone/docs/bundled-plugins
Sitemap
Generates /sitemap.xml for search engines.
'sitemap' => [
'enabled' => true,
],
Exclude pages with noindex: true in frontmatter. CLI: ./ava sitemap:stats
RSS Feed
Generates /feed.xml for RSS readers.
'feed' => ['enabled' => true, 'items_per_feed' => 20, 'full_content' => false, 'types' => null],
Add to theme: <link rel="alternate" type="application/rss+xml" href="/feed.xml">
Redirects
Manage URL redirects via CLI. Stored in storage/redirects.json.
Status codes: 301, 302, 307, 308 (redirects), 410, 451, 503 (status-only)
Alternative: Use redirect_from: in content frontmatter for content-based redirects.
Performance
Full docs: https://ava.addy.zone/docs/performance
Two-layer system: Content Indexing + Webpage Caching.
Content Index
Pre-built binary index of content metadata. Avoids parsing Markdown on every request.
Cache files: storage/cache/ — recent_cache.bin, slug_lookup.bin, content_index.bin, tax_index.bin, routes.bin
| Tier | Cache | Use Case | Speed |
|---|---|---|---|
| 1 | Recent | Homepage, RSS | ~0.2ms |
| 2 | Slug Lookup | Single item | ~1-15ms |
| 3 | Full Index | Search, pagination | ~15-300ms |
Backends: array (default, fastest) or sqlite (10k+ items, lower memory)
| Mode | Behavior |
|---|---|
auto |
Rebuild on file changes (development) |
never |
Only via ./ava rebuild (production) |
always |
Every request (debugging only) |
Webpage Cache
Stores fully-rendered HTML.
'webpage_cache' => ['enabled' => true, 'ttl' => null, 'exclude' => ['/api/*']],
Speed: Uncached ~5ms → Cached ~0.02ms (250× faster)
Per-page control: cache: false in frontmatter
Not cached: POST requests, query strings
Invalidation: ./ava rebuild clears both caches (use --keep-webcache to preserve webpage cache)
Recommendations: Development: mode: auto, cache disabled. Production: mode: never, cache enabled, ./ava rebuild after deploys.
Hosting
Full docs: https://ava.addy.zone/docs/hosting
Requirements: PHP 8.3+, Composer, SSH access recommended.
Structure: Only public/ should be web-accessible. Keep app/, content/, core/, storage/ above web root.
Local Development: php -S localhost:8000 -t public
Nginx:
server {
root /path/to/public;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \.php$ { fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; include fastcgi_params; }
}
Deployment: git pull && composer install --no-dev && ./ava rebuild
Pre-Launch: PHP 8.3+, HTTPS enabled, ./ava rebuild run, debug disabled, cache enabled.
Updates
Full docs: https://ava.addy.zone/docs/updating
./ava update:check # Check for updates (cached 1 hour)
./ava update:check --force # Bypass cache
./ava update:apply # Download and apply latest release
./ava update:apply --yes # Skip confirmation
./ava update:apply --dev # Update from main branch (dev/testing only)
./ava update:stale # Scan for stale files
./ava update:stale --clean # Remove stale files
Updated: core/, ava, bootstrap.php, composer.json, composer.lock, public/index.php, public/.htaccess, index.php, .htaccess, nginx.conf.example, bundled plugins (clean sync). Preserved: content/, app/config/, app/themes/, app/snippets/, vendor/, storage/, custom plugins.
Post-update (automatic): Finalize (remove stale dirs) → composer install --no-dev → ./ava rebuild
Custom paths block updates: If paths.themes, paths.plugins, or paths.snippets differ from defaults, auto-update is blocked.
Version format: CalVer YY.M.PATCH (e.g., 26.4.1). Dev mode: {current}-dev.{shortsha}.
API
Full docs: https://ava.addy.zone/docs/api
Building blocks for custom APIs (no predefined structure).
Request/Response
$request->method() // GET, POST, etc.
$request->query('key', $default) // Query parameter
$request->header('X-Api-Key') // Header
$request->body() // Request body
Response::json($data, 200) // JSON response
Response::redirect($url, 302) // Redirect
Response::json(['ok' => true])->withHeader('Cache-Control', 'no-store')
JSON API Example
$router->addRoute('/api/posts', function($request, $params) use ($app) {
$posts = $app->query()->type('post')->published()
->orderBy('date', 'desc')->perPage(10)->get();
return \Ava\Http\Response::json([
'data' => array_map(fn($p) => ['title' => $p->title(), 'slug' => $p->slug()], $posts),
]);
});
Authentication Pattern
$apiKey = $request->header('X-API-Key') ?? $request->query('api_key');
$validKeys = $app->config('api.keys', []);
if (!in_array($apiKey, $validKeys, true)) {
return Response::json(['error' => 'Unauthorized'], 401);
}
Store keys: 'api' => ['keys' => ['your-secret-key']] in ava.php
License
Ava CMS is released under the GNU General Public License v3.0 (GPL-3.0). This means you are free to use, modify, and distribute it, but any derivative works must also be released under the GPL. When using AI assistants to generate code for Ava CMS projects, please ensure the generated code respects the GPL license terms.