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 Admin dashboard pages
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, templates render, or the admin builds.

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
admin.register_pages Filter When admin pages are registered
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

Admin Pages

Add pages to the admin dashboard. They're automatically protected by authentication.

Register a Page

use Ava\Plugins\Hooks;
use Ava\Http\Request;
use Ava\Application;

Hooks::addFilter('admin.register_pages', function(array $pages) {
    $pages['my-plugin'] = [
        'label' => 'My Plugin',     // Sidebar text
        'icon' => 'extension',      // Material Symbols icon
        'handler' => function(Request $request, Application $app, $controller) {
            $content = '<div class="card">
                <div class="card-body">Your content here</div>
            </div>';
            
            return $controller->renderPluginPage([
                'title' => 'My Plugin',
                'activePage' => 'my-plugin',
            ], $content);
        },
    ];
    return $pages;
});

Plugin pages appear in the "Plugins" section of the sidebar.

Finding icons: Browse Material Symbols for icon names.

renderPluginPage() Options

Option Description
title Browser tab title
heading Page heading (defaults to title)
icon Header icon
activePage Sidebar item to highlight
headerActions HTML for header buttons
alertSuccess Green success message
alertError Red error message
alertWarning Yellow warning message
scripts Additional JavaScript

Handling Forms

'handler' => function(Request $request, Application $app, $controller) {
    $configFile = $app->path('storage/my-plugin.json');
    $config = file_exists($configFile) 
        ? json_decode(file_get_contents($configFile), true) 
        : [];
    
    $success = null;
    $error = null;
    
    if ($request->isMethod('POST')) {
        if (!$controller->auth()->verifyCsrf($request->post('_csrf', ''))) {
            $error = 'Invalid request.';
        } else {
            $config['api_key'] = $request->post('api_key', '');
            file_put_contents($configFile, json_encode($config, JSON_PRETTY_PRINT));
            $success = 'Settings saved!';
            $controller->auth()->regenerateCsrf();
        }
    }
    
    $csrf = $controller->auth()->csrfToken();
    
    $content = '<div class="card">
        <div class="card-body">
            <form method="POST">
                <input type="hidden" name="_csrf" value="' . $csrf . '">
                <div class="form-group">
                    <label>API Key</label>
                    <input type="text" name="api_key" value="' . htmlspecialchars($config['api_key'] ?? '') . '" class="form-control">
                </div>
                <button type="submit" class="btn btn-primary">Save</button>
            </form>
        </div>
    </div>';
    
    return $controller->renderPluginPage([
        'title' => 'Settings',
        'activePage' => 'my-plugin',
        'alertSuccess' => $success,
        'alertError' => $error,
    ], $content);
},

Available CSS Classes

Class Use for
.card, .card-header, .card-body Content cards
.stat-grid, .stat-card, .stat-label, .stat-value Statistics display
.btn, .btn-primary, .btn-secondary, .btn-sm Buttons
.badge, .badge-success, .badge-warning, .badge-muted Status indicators
.alert, .alert-success, .alert-danger, .alert-warning Messages
.table, .table-wrap Data tables
.form-group, .form-control Form elements

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

Admin Assets

Serve CSS/JS for admin pages via /admin-assets/{plugin-name}/{file}:

'handler' => function($request, $app, $controller) {
    $pluginPath = $app->configPath('plugins') . '/my-plugin/assets';
    $cssFile = $pluginPath . '/styles.css';
    $cssUrl = '/admin-assets/my-plugin/styles.css';
    if (file_exists($cssFile)) {
        $cssUrl .= '?v=' . filemtime($cssFile);
    }
    
    $content = '<link rel="stylesheet" href="' . $cssUrl . '">
        <div class="card">...</div>';
    
    return $controller->renderPluginPage([...], $content);
},

Assets are cached with max-age=31536000, so always use ?v={timestamp} for cache busting.

Supported types: CSS, JS, JSON, images (SVG, PNG, JPG, GIF, WebP, ICO), fonts (WOFF, WOFF2, TTF, EOT).

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 admin page, CLI command, and frontend route:

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

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

return [
    'name' => 'Link Checker',
    'version' => '1.0.0',
    'description' => 'Scans content for broken internal links',
    
    'boot' => function($app) {
        // Admin page
        Hooks::addFilter('admin.register_pages', function(array $pages) {
            $pages['link-checker'] = [
                'label' => 'Link Checker',
                'icon' => 'link_off',
                'handler' => function(Request $request, Application $app, $controller) {
                    $broken = findBrokenLinks($app);
                    
                    if (empty($broken)) {
                        $content = '<div class="card"><div class="card-body" style="text-align:center;padding:3rem">
                            <span class="material-symbols-rounded" style="font-size:3rem;color:var(--success)">check_circle</span>
                            <h3>No Broken Links</h3>
                        </div></div>';
                    } else {
                        $rows = implode('', array_map(fn($l) => 
                            '<tr><td>' . htmlspecialchars($l['page']) . '</td><td><code>' . htmlspecialchars($l['url']) . '</code></td></tr>', 
                            $broken
                        ));
                        $content = '<div class="card"><div class="table-wrap">
                            <table class="table"><thead><tr><th>Page</th><th>Broken Link</th></tr></thead>
                            <tbody>' . $rows . '</tbody></table>
                        </div></div>';
                    }
                    
                    return $controller->renderPluginPage([
                        'title' => 'Link Checker',
                        'icon' => 'link_off',
                        'activePage' => 'link-checker',
                    ], $content);
                },
            ];
            return $pages;
        });
        
        // 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