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.

Creating Plugins

Plugins let you extend Ava CMS with reusable, shareable functionality that lives outside your theme.

Plugins vs theme.php

You might wonder: "Can't I just put everything in theme.php?"

Yes! For simple sites, theme.php is often all you need. But plugins are better when:

Use theme.php Use a Plugin
Theme-specific features Features that work with any theme
Site customisations Code you want to share with others
Simple hooks and shortcodes Custom routes and CLI commands
Quick, one-off additions CLI commands

Think of it this way: If you switch themes, anything in theme.php disappears. Plugins survive theme changes because they live in a separate folder.

The bundled plugins (sitemap, feed, redirects) are good examples — they work regardless of which theme you use.

Your First Plugin

  1. Create a folder: app/plugins/my-plugin/
  2. Create a file: app/plugins/my-plugin/plugin.php
<?php
return [
    'name' => 'My First Plugin',
    'version' => '1.0',
    
    'boot' => function($app) {
        // Your code runs here when the plugin loads
        
        // Example: Add a custom route
        $app->router()->addRoute('/hello', function() {
            return new \Ava\Http\Response('Hello World!');
        });
    }
];
  1. Enable it in app/config/ava.php:
'plugins' => [
    'sitemap',
    'feed',
    'my-plugin',  // Add your plugin here
],

That's it! Visit /hello and see your plugin in action.

What is $app?

The $app object is your gateway to everything in Ava CMS. It's passed to your plugin's boot function:

Method Returns
$app->router() Router — add custom routes
$app->repository() Content repository — fetch pages and posts
$app->query() Content query builder with filtering/pagination
$app->config('key') Configuration values
$app->path('relative') Absolute file paths
$app->configPath('key') Paths from config (e.g., 'storage', 'plugins')
$app->renderer() Template rendering engine
$app->shortcodes() Shortcode registration

See the API documentation for detailed usage.

Plugin Structure

<?php
// app/plugins/my-plugin/plugin.php

return [
    // Required
    'name' => 'My Plugin',
    'boot' => function($app) { ... },

    // Recommended metadata
    'version' => '1.0.0',
    'description' => 'What this plugin does',
    'author' => 'Your Name',

    // Optional
    'url' => 'https://example.com/plugin',
    'license' => 'GPLv3',

    // Optional: CLI commands
    'commands' => [ ... ],
];

Plugins load in the order listed in app/config/ava.php. If plugin B depends on hooks from plugin A, list A first.

Hooks

Hooks let your code run at specific moments — when content loads or templates render.

Filters vs Actions

Filters modify data and must return it:

use Ava\Plugins\Hooks;

Hooks::addFilter('render.context', function($context) {
    $context['year'] = date('Y');
    return $context;  // Must return!
});

Actions react to events without returning data:

Hooks::addAction('indexer.rebuild', function($app) {
    file_put_contents('rebuild.log', date('c') . "\n", FILE_APPEND);
});

Priority

Multiple callbacks run in priority order (lower first, default 10):

Hooks::addFilter('render.context', $earlyCallback, 5);   // Runs first
Hooks::addFilter('render.context', $normalCallback);      // Priority 10
Hooks::addFilter('render.context', $lateCallback, 100);   // Runs last

Available Hooks

Hook Type When it fires
router.before_match Filter Before routing — return Response to intercept
content.loaded Filter After content item loads from repository
render.context Filter Before template rendering — add template variables
render.output Filter After rendering — modify final HTML
markdown.configure Action When Markdown parser initializes
indexer.rebuild Action After any content index rebuild
cli.rebuild Action After CLI rebuild command only

Hook Examples

Intercept routing:

use Ava\Http\Response;

Hooks::addFilter('router.before_match', function($match, $request, $router) {
    if ($request->path() === '/old-page') {
        return Response::redirect('/new-page', 301);
    }
    return $match;
});

Add template variables:

Hooks::addFilter('render.context', function($context) {
    if (isset($context['content'])) {
        $words = str_word_count(strip_tags($context['content']->rawContent()));
        $context['reading_time'] = max(1, (int) ceil($words / 200));
    }
    return $context;
});

Modify final HTML:

Hooks::addFilter('render.output', function($output, $templatePath, $context) {
    return str_replace('</body>', '<script src="/tracking.js"></script></body>', $output);
});

Add Markdown extensions:

use League\CommonMark\Extension\Table\TableExtension;

Hooks::addAction('markdown.configure', function($environment) {
    $environment->addExtension(new TableExtension());
});

Frontend Routes

Create custom public URLs for APIs, landing pages, or dynamic content.

Basic Route

use Ava\Http\Request;
use Ava\Http\Response;

'boot' => function($app) {
    $app->router()->addRoute('/api/posts', function(Request $request) use ($app) {
        $posts = $app->query()->type('post')->published()->get();
        
        return Response::json([
            'count' => count($posts),
            'posts' => array_map(fn($p) => [
                'title' => $p->title(),
                'slug' => $p->slug(),
                'excerpt' => $p->excerpt(),
            ], $posts),
        ]);
    });
}

URL Parameters

Use {param} placeholders:

$router->addRoute('/api/posts/{slug}', function(Request $request, array $params) use ($app) {
    $post = $app->repository()->get('post', $params['slug']);
    
    if (!$post) {
        return Response::json(['error' => 'Not found'], 404);
    }
    
    return Response::json([
        'title' => $post->title(),
        'content' => $post->html(),
    ]);
});

Form Handling

$router->addRoute('/contact', function(Request $request) use ($app) {
    if ($request->isMethod('POST')) {
        $name = $request->post('name', '');
        $email = $request->post('email', '');
        
        if (empty($name) || empty($email)) {
            return Response::json(['error' => 'Name and email required'], 400);
        }
        
        // Save, send email, etc.
        return Response::json(['success' => true]);
    }
    
    return $app->renderer()->render('contact');
});

Prefix Routes

Match any URL starting with a path (checked after exact routes):

$router->addPrefixRoute('/api/', function(Request $request) {
    return Response::json(['error' => 'Endpoint not found'], 404);
});

Response Methods

Method Description
Response::json($data, $status) JSON response
Response::html($html, $status) HTML response
Response::redirect($url, $status) Redirect (301, 302, etc.)
Response::text($text, $status) Plain text
Response::notFound($message) 404 response

CLI Commands

Add commands that appear in ./ava help.

return [
    'name' => 'My Plugin',
    
    'commands' => [
        [
            'name' => 'myplugin:status',
            'description' => 'Show plugin status',
            'handler' => function(array $args, $cli, \Ava\Application $app) {
                $cli->header('My Plugin Status');
                $cli->success('Everything is working!');
                return 0;
            },
        ],
    ],
];

Handler Parameters

Parameter Description
$args Arguments after command name
$cli CLI output helper
$app Application instance

Output Methods

$cli->header('Section Title');      // Bold header
$cli->info('Note');                 // ℹ Cyan
$cli->success('Done!');             // ✓ Green
$cli->warning('Careful');           // ⚠ Yellow
$cli->error('Failed');              // ✗ Red
$cli->writeln('Plain text');
$cli->table(['Col1', 'Col2'], [['a', 'b'], ['c', 'd']]);

// Text formatting (returns string)
$cli->bold('text');
$cli->dim('text');
$cli->green('text');
$cli->yellow('text');
$cli->red('text');

Return Values

Return 0 for success, 1 or higher for errors.

Plugin Assets

Frontend Assets

Add to template context, then include in your theme's <head>:

// In plugin
Hooks::addFilter('render.context', function($context) {
    $context['plugin_assets'][] = '/app/plugins/my-plugin/assets/style.css';
    return $context;
});
<!-- In theme layout -->
<?php foreach ($plugin_assets ?? [] as $asset): ?>
    <?php if (str_ends_with($asset, '.css')): ?>
        <link rel="stylesheet" href="<?= $asset ?>">
    <?php else: ?>
        <script src="<?= $asset ?>"></script>
    <?php endif; ?>
<?php endforeach; ?>

Shortcodes

Plugins can register custom shortcodes:

$app->shortcodes()->register('greeting', function(array $attrs, ?string $content) {
    $name = $attrs['name'] ?? 'friend';
    return "Hello, " . htmlspecialchars($name) . "!";
});

See the Shortcodes documentation for full details.

Complete Example

A plugin with CLI command and frontend route:

<?php
// app/plugins/link-checker/plugin.php

use Ava\Plugins\Hooks;
use Ava\Http\Response;

return [
    'name' => 'Link Checker',
    'version' => '1.0.0',
    'description' => 'Scans content for broken internal links',
    
    'boot' => function($app) {
        // JSON API endpoint
        $app->router()->addRoute('/api/broken-links', function() use ($app) {
            return Response::json(findBrokenLinks($app));
        });
    },
    
    'commands' => [
        [
            'name' => 'links:check',
            'description' => 'Check for broken internal links',
            'handler' => function($args, $cli, $app) {
                $cli->header('Checking Links');
                $broken = findBrokenLinks($app);
                
                foreach ($broken as $link) {
                    $cli->writeln('  ' . $cli->red('✗') . ' ' . $link['page'] . ': ' . $link['url']);
                }
                
                if (empty($broken)) {
                    $cli->success('All links valid!');
                    return 0;
                }
                
                $cli->error('Found ' . count($broken) . ' broken link(s)');
                return 1;
            },
        ],
    ],
];

function findBrokenLinks($app): array {
    $repo = $app->repository();
    $validPaths = array_keys($repo->routes()['exact'] ?? []);
    $broken = [];
    
    foreach ($repo->types() as $type) {
        foreach ($repo->published($type) as $item) {
            preg_match_all('/\[([^\]]+)\]\(([^)]+)\)/', $item->rawContent(), $matches);
            foreach ($matches[2] as $url) {
                if (str_starts_with($url, '/') && !str_starts_with($url, '//')) {
                    $path = strtok($url, '#?');
                    if (!in_array($path, $validPaths) && $path !== '/') {
                        $broken[] = ['page' => $item->title(), 'url' => $url];
                    }
                }
            }
        }
    }
    return $broken;
}

Next Steps