Development of Custom Grav Plugin
A Grav plugin is a PHP class that subscribes to lifecycle request events. Grav publishes over 30 events: from initialization to page rendering and response sending. A plugin intercepts needed events and modifies system behavior without changing the core.
Plugin Structure
user/plugins/my-plugin/
my-plugin.php # main plugin class
my-plugin.yaml # default configuration
blueprints.yaml # metadata (for GPM and admin)
languages.yaml # UI translations
README.md
CHANGELOG.md
classes/ # helper classes
MyService.php
templates/ # Twig templates if plugin adds them
my-plugin.html.twig
assets/
css/
my-plugin.css
js/
my-plugin.js
blueprints.yaml
name: My Plugin
version: 1.2.0
description: Plugin functionality description
icon: plug
author:
name: Dev Name
email: [email protected]
homepage: https://example.com
bugs: https://github.com/user/grav-plugin-my-plugin/issues
license: MIT
dependencies:
- { name: grav, version: '>=1.7.0' }
- { name: form, version: '>=7.0.0' }
form:
validation: strict
fields:
enabled:
type: toggle
label: Plugin Status
highlight: 1
default: 0
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
api_key:
type: text
label: API Key
size: large
cache_ttl:
type: number
label: Cache TTL (seconds)
default: 3600
validate:
type: int
min: 60
max: 86400
Main Plugin Class
<?php
// my-plugin.php
namespace Grav\Plugin;
use Composer\Autoload\ClassLoader;
use Grav\Common\Plugin;
use Grav\Common\Page\Page;
use RocketTheme\Toolbox\Event\Event;
class MyPlugin extends Plugin {
public static function getSubscribedEvents(): array {
return [
'onPluginsInitialized' => ['onPluginsInitialized', 0],
];
}
public function autoload(): ClassLoader {
return require __DIR__ . '/vendor/autoload.php';
}
public function onPluginsInitialized(): void {
if ($this->isAdmin()) {
return; // don't execute in admin
}
if (!$this->config->get('plugins.my-plugin.enabled')) {
return;
}
$this->enable([
'onPageInitialized' => ['onPageInitialized', 0],
'onPageContentRaw' => ['onPageContentRaw', 0],
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
'onTwigSiteVariables' => ['onTwigSiteVariables', 0],
'onOutputGenerated' => ['onOutputGenerated', -10],
]);
}
public function onPageInitialized(Event $event): void {
/** @var Page $page */
$page = $event['page'];
// Skip pages without required header
if (!isset($page->header()->my_plugin)) {
return;
}
// Add CSS/JS to page
$this->grav['assets']->addCss('plugin://my-plugin/assets/css/my-plugin.css');
$this->grav['assets']->addJs('plugin://my-plugin/assets/js/my-plugin.js', ['loading' => 'defer']);
}
public function onPageContentRaw(Event $event): void {
/** @var Page $page */
$page = $event['page'];
$raw = $page->getRawContent();
// Replace shortcode [my-tag attr="val"]...[/my-tag]
$processed = preg_replace_callback(
'/\[my-tag([^\]]*)\](.*?)\[\/my-tag\]/s',
function(array $matches): string {
$attrs = $this->parseAttrs($matches[1]);
$content = $matches[2];
return $this->renderTag($attrs, $content);
},
$raw
);
$page->setRawContent($processed);
}
public function onTwigTemplatePaths(): void {
$this->grav['twig']->twig_paths[] = __DIR__ . '/templates';
}
public function onTwigSiteVariables(): void {
$this->grav['twig']->twig_vars['my_plugin_data'] = $this->getPluginData();
}
public function onOutputGenerated(): void {
$output = $this->grav->output;
// Inject analytics code before </body>
$snippet = '<script>/* analytics */</script>';
$this->grav->output = str_replace('</body>', $snippet . '</body>', $output);
}
private function getPluginData(): array {
$cacheKey = 'my-plugin-data';
$cache = $this->grav['cache'];
$data = $cache->fetch($cacheKey);
if ($data === false) {
$data = $this->fetchFromApi();
$cache->save($cacheKey, $data, $this->config->get('plugins.my-plugin.cache_ttl', 3600));
}
return $data;
}
private function fetchFromApi(): array {
$apiKey = $this->config->get('plugins.my-plugin.api_key');
$ch = curl_init("https://api.example.com/v1/data");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $apiKey"],
CURLOPT_TIMEOUT => 5,
]);
$result = curl_exec($ch);
curl_close($ch);
return json_decode($result, true) ?? [];
}
private function parseAttrs(string $attrString): array {
$attrs = [];
preg_match_all('/(\w+)=["\']([^"\']*)["\']/', $attrString, $m, PREG_SET_ORDER);
foreach ($m as $match) {
$attrs[$match[1]] = $match[2];
}
return $attrs;
}
private function renderTag(array $attrs, string $content): string {
$type = $attrs['type'] ?? 'info';
return "<div class=\"my-tag my-tag--$type\">$content</div>";
}
}
Plugin with REST API Endpoints
Grav allows registering routes via onTask event:
// In getSubscribedEvents():
'onTask.myPlugin.submit' => ['onTaskSubmit', 0],
// or via Grav 1.7+ routes:
'onPagesInitialized' => ['registerRoutes', 0],
public function registerRoutes(): void {
$this->grav['router']->addRoute('/api/my-plugin/data', ['GET'], function() {
header('Content-Type: application/json');
echo json_encode($this->getPluginData());
exit;
});
}
my-plugin.yaml — Configuration
enabled: false
api_key: ''
cache_ttl: 3600
debug: false
allowed_pages:
- /services
- /blog
Reading in plugin: $this->config->get('plugins.my-plugin.api_key').
Override Configuration for Pages
Plugin config can be overridden in page frontmatter:
---
title: Special Page
my-plugin:
cache_ttl: 60
debug: true
---
// In plugin — get config with page-override
$config = $this->mergeConfig($this->grav['page']);
$ttl = $config->get('cache_ttl', 3600);
Plugin Testing
# Enable debug mode
# user/config/system.yaml: debugger.enabled: true
# See available events
bin/grav plugin my-plugin list-events
# Clear cache after changes
bin/grav cache:clear
Development Timelines
| Plugin Type | Timeline |
|---|---|
| Shortcode / content processing | 4–12 h |
| External API integration + cache | 1–3 days |
| Custom form with processing | 1–2 days |
| REST API endpoints (3–5 routes) | 1–2 days |
| Full functional plugin with UI | 3–7 days |







