Skip to content

Grav Plugin Architecture — Structure, Events and DI Container

DodaTech Updated 2026-06-27 7 min read

In this tutorial, you'll learn Grav plugin architecture — the plugin folder structure, event system, Dependency Injection container, service providers, and how to build a well-structured plugin from scratch.

What You'll Learn

  • Grav plugin folder structure and required files
  • The event system: how events, subscribers, and hooks work
  • The DI container: accessing Grav services
  • Plugin configuration and blueprints
  • Building a complete plugin from scratch
  • Best practices for plugin development

Why It Matters

In WordPress, plugins use hooks (actions and filters) and a global functions API. In Grav, plugins use an event system similar to Symfony. Events are dispatched at specific points in the request lifecycle, and plugins subscribe to the events they care about. This architecture is cleaner, more testable, and more predictable than global hooks. Understanding the plugin architecture lets you extend Grav in any way imaginable — from adding a simple shortcode to building a full-featured e-commerce system.

Real-World Use

A team needs to generate a sitemap automatically when pages are saved. They build a plugin that subscribes to the onPageSaved event. Every time a page is saved, the plugin regenerates the XML sitemap. No cron job needed, no manual regeneration. The plugin is a single PHP class with one event subscriber method.

Learning Path

flowchart LR
    A["Theme Languages"] --> B["Plugin Architecture
← You are here"]:::current B --> C["Plugin Events"] C --> D["Plugin Forms"] D --> E["Plugin Admin"] E --> F["Plugin CLI"] F --> G["Multilingual"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

Plugin Folder Structure

A Grav plugin follows a standard structure:

user/plugins/myplugin/
├── myplugin.php                 # Plugin bootstrap
├── myplugin.yaml                # Default configuration
├── blueprints.yaml              # Admin configuration form
├── languages/                   # Translation files
│   └── en.yaml
├── classes/                     # PHP classes
│   ├── MyPlugin.php
│   └── TwigExtension.php
├── templates/                   # Plugin templates
│   └── partials/
├── assets/                      # Plugin assets
│   ├── css/
│   └── js/
├── vendor/                      # Composer dependencies
└── README.md

Required Files

Every plugin needs two files at minimum:

  1. Plugin Bootstrap (myplugin.php) — registers events
  2. Default config (myplugin.yaml) — default configuration values

The Plugin Bootstrap File

user/plugins/myplugin/myplugin.php:

<?php
namespace Grav\Plugin;

use Grav\Common\Plugin;
use Grav\Common\Page\Page;

class MyPlugin extends Plugin
{
    // Event subscription method
    public static function getSubscribedEvents()
    {
        return [
            'onPluginsInitialized' => ['onPluginsInitialized', 0],
            'onPageContentRaw' => ['onPageContentRaw', 0],
            'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
        ];
    }

    // Called when all plugins are initialized
    public function onPluginsInitialized()
    {
        // Only run on non-admin pages
        if ($this->isAdmin()) {
            return;
        }

        echo 'MyPlugin initialized!';
    }

    // Called before page content is processed
    public function onPageContentRaw(Event $event)
    {
        $page = $event['page'];
        $content = $page->getRawContent();
        // Modify content
        $page->setRawContent($content);
    }

    // Add plugin template paths
    public function onTwigTemplatePaths()
    {
        $this->grav['twig']->twig_paths[] = __DIR__ . '/templates';
    }
}

The Event System

Grav dispatches events at specific points during the request lifecycle. Plugins subscribe to events to take action.

Subscribing to Events

The getSubscribedEvents() method returns an array of event names with handler methods:

public static function getSubscribedEvents()
{
    return [
        'onPageContentRaw' => ['onPageContentRaw', 0],
        //      event name        handler    priority
    ];
}

Priority determines execution order. Lower priority values run earlier. Default is 0. Range is typically 0-10.

Event Lifecycle

Major events in order:

  1. onPluginsInitialized — All plugins loaded
  2. onPageContentRaw — Raw Markdown available for modification
  3. onPageProcessed — Page processed (YAML parsed, content ready)
  4. onTwigTemplatePaths — Template paths being collected
  5. onTwigSiteVariables — Twig variables being prepared
  6. onTwigExtensions — Twig extensions being registered
  7. onOutputGenerated — Final HTML output ready to send

The Event Object

Events pass an array-like object:

public function onPageContentRaw(Event $event)
{
    $page = $event['page'];
    $content = $event['content'];

    // Or access through Grav
    $grav = $this->grav;
    $uri = $this->grav['uri'];
    $config = $this->grav['config'];
}

The DI Container

Grav uses a dependency injection container (Pimple-based) to manage services:

// Access services through the container
$this->grav['cache']        // Cache service
$this->grav['config']       // Configuration service
$this->grav['uri']          // URI handling
$this->grav['pages']        // Page management
$this->grav['twig']         // Twig templating
$this->grav['assets']       // Asset manager
$this->grav['language']     // Language handling
$this->grav['plugins']      // Plugin management
$this->grav['session']      // Session handling
$this->grav['accounts']     // User accounts
$this->grav['inflector']    // String manipulation

Adding Custom Services

Plugins can register custom services:

public function onPluginsInitialized()
{
    $this->grav['myplugin.service'] = function($c) {
        return new MyService($c['config'], $c['cache']);
    };
}

Plugin Configuration

Default Config

user/plugins/myplugin/myplugin.yaml:

enabled: true
api_key: ''
cache_duration: 3600
items_per_page: 10
features:
    enable_search: true
    enable_sharing: false

Blueprints for Admin

user/plugins/myplugin/blueprints.yaml:

name: My Plugin
version: 1.0.0
description: A custom Grav plugin
icon: plug
author:
    name: DodaTech
    email: support@dodatech.com
keywords: grav, plugin, custom

form:
    validation: loose
    fields:
        enabled:
            type: toggle
            label: Plugin enabled
            default: 1

        api_key:
            type: text
            label: API Key
            size: large

        cache_duration:
            type: range
            label: Cache duration (seconds)
            default: 3600
            validate:
                min: 60
                max: 86400

        features.enable_search:
            type: toggle
            label: Enable search
            default: 1

Accessing Configuration

public function onPageContentRaw()
{
    $enabled = $this->grav['config']->get('plugins.myplugin.enabled');
    $api_key = $this->grav['config']->get('plugins.myplugin.api_key');
    $features = $this->grav['config']->get('plugins.myplugin.features');
}

In Twig:

{{ config.plugins.myplugin.api_key }}

The Plugin Class Hierarchy

Grav\Common\Plugin               # Base plugin class
    └── MyPlugin                  # Your plugin
        └── methods:
            getSubscribedEvents() # Register event subscribers
            onPluginsInitialized() # Initialize plugin

The base Plugin class provides:

  • $this->grav — access to DI container
  • $this->config() — plugin configuration
  • $this->isAdmin() — check if in admin context
  • $this->enable() — enable specific events
  • $this->mergeConfig() — merge config with defaults

Learning Path

flowchart LR
    A["Theme Languages"] --> B["Plugin Architecture
← You are here"]:::current B --> C["Plugin Events"] C --> D["Plugin Forms"] D --> E["Plugin Admin"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

Common Mistakes

  1. Not checking isAdmin() for frontend-only code: If your plugin should only run on frontend pages, check if ($this->isAdmin()) { return; } first. Without this, the plugin runs in the Admin panel too.

  2. Overriding getSubscribedEvents() incorrectly: The method must be public static and return an array. Each key is an event name; each value is an array with handler method and priority.

  3. Not using namespaces: All plugin classes should use namespaces to avoid conflicts. The convention is Grav\Plugin\YourPlugin.

  4. Hardcoding configuration: Use $this->grav['config']->get() instead of hardcoding values. Configuration in YAML files can be changed through the Admin panel.

  5. Ignoring cache: If your plugin reads data from files or APIs, cache the results. Use $this->grav['cache']->fetch() and ->save() to avoid repeating expensive operations.

Practice Questions

  1. What method must a plugin implement to subscribe to events? Answer: getSubscribedEvents() — a static method that returns an array mapping event names to handler methods with priority values.

  2. How do you access Grav services from within a plugin? Answer: Through the DI container: $this->grav['cache'], $this->grav['config'], $this->grav['pages'], etc. Each service is accessible as a key on the container.

  3. What is the purpose of blueprints.yaml in a plugin? Answer: It defines form fields for the Admin panel, allowing site administrators to configure the plugin through the UI. It also provides metadata about the plugin (name, version, author).

  4. How do you prevent a plugin from executing in the Admin panel? Answer: Call if ($this->isAdmin()) { return; } at the beginning of onPluginsInitialized(). This ensures plugin code only runs on the frontend.

  5. Challenge: Build a complete plugin from scratch that tracks page views. Include proper folder structure, event subscription, configuration in YAML, admin blueprints, Caching for performance, a service class for business logic, Twig extension for template access, and language files for translations. The plugin should store page view counts in the cache and display them with a Twig function.

FAQ

What is the minimum number of files needed for a Grav plugin?

Two: the plugin bootstrap file (myplugin.php) and the default configuration file (myplugin.yaml). The bootstrap file registers event subscribers, and the config file provides default settings.

How does Grav's plugin autoloading work?

Grav uses Composer's autoloading and PSR-4. The plugin's namespace is typically Grav\\Plugin\\YourPlugin. Grav automatically discovers plugins in user/plugins/ and loads the bootstrap file.

Can I use Composer dependencies in a Grav plugin?

Yes. Run composer init in the plugin directory and require dependencies. Grav loads the plugin's vendor/autoload.php automatically when the plugin is enabled.

What is the difference between `enable()` and subscribing in `getSubscribedEvents()`?

getSubscribedEvents() statically declares all events a plugin listens to. enable() is called dynamically, usually inside onPluginsInitialized(), to subscribe to additional events conditionally.

How do I test a Grav plugin?

Create a test Grav installation, enable the plugin, and test manually. For automated testing, use PHPUnit with Grav's test framework. Test event handlers by triggering events and asserting expected behavior.

Mini Project

Goal: Build a complete Grav plugin with proper architecture.

  1. Create plugin directory with bootstrap file, config, and blueprints
  2. Subscribe to 3 different events
  3. Register a custom Twig extension
  4. Add multilingual support with language files
  5. Implement caching for expensive operations
  6. Add plugin configuration with admin blueprints
  7. Create a service class for business logic
  8. Add template overrides through Twig template paths
  9. Add proper error handling with try-catch blocks
  10. Test the plugin and verify event execution order

What's Next

Now you understand plugin architecture. Next, explore all Grav events in detail:

Continue to Lesson 25: Plugin Events — Complete event reference, onPageContentRaw, onTwigSiteVariables, and all lifecycle events.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro