Grav Custom Plugins: Events, Twig & REST API

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.

Development and maintenance of all types of websites:

Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:

Development stages

Latest works

  • image_website-b2b-advance_0.webp
    B2B ADVANCE company website development
    1365
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1254
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    961
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1192
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    933
  • image_bitrix-bitrix-24-1c_fixper_448_0.webp
    Website development for FIXPER company
    951

Imagine needing to output data from a CRM on every Grav page, but the default infrastructure lacks the extensibility for such customization. Or requiring a custom shortcode that parses content and inserts a widget with dynamic data. Without core changes—only a plugin leverages the Grav event model. This article provides a technical breakdown of Grav plugin development architecture, typical problems, and our approach. Custom Grav plugin development leverages the event system for seamless integration. Consider a case: integration with a weather API—data updates every hour, but the page must load quickly. We developed a plugin that caches the response for 3600 seconds and inserts via shortcode. As a result, LCP improved by 200 ms, API load reduced by 5 times, and API costs dropped by $100 per month. According to Grav Documentation, plugins are the primary way of extending Grav functionality.

Plugin Installation

To install a custom plugin, download the archive, extract into user/plugins/my-plugin. Then enable the plugin via admin or console: bin/grav plugin enable my-plugin. After that, configure parameters in user/config/plugins/my-plugin.yaml.

Problems We Solve

Shortcode and Content Processing

Standard Grav does not support custom shortcodes like [weather city="Minsk"]. The plugin intercepts onPageContentRaw, parses content, and replaces the shortcode with an HTML widget. This allows designers to insert dynamic blocks without PHP knowledge. We have solved this for over 30 clients, reducing development time by 20 hours per month.

External API Integration

External APIs often have request limits. The plugin caches responses in Grav's built-in cache, setting TTL via config. For example, with TTL=600 seconds, API requests drop by 90%, and pages load in 0.3 seconds instead of 2 seconds. This results in a cache hit rate of 95%. Grav cache can use file storage or Redis—read times are microseconds. Consequently, the cache hit ratio remains high, drastically reducing I/O operations on the filesystem or Redis.

Output Modification

Need to add an analytics script to all pages without editing templates? The plugin subscribes to onOutputGenerated and inserts the script before </body>. This is simpler than editing every Twig template.

Plugin Architecture

Event Subscription

Grav is built on an event-driven architecture: a plugin is a PHP class that subscribes to the request lifecycle. The system publishes over 40 events: from initialization to rendering and response delivery. The plugin intercepts needed events and modifies behavior without core changes. Event priorities allow precise control of execution order. Refer to Grav event hooks documentation for deeper insights.

The main plugin class extends Grav\Common\Plugin. It overrides the getSubscribedEvents() method, which returns an array of events with priorities. Then, a handler method is implemented. The plugin class must implement the autoload method to include Composer dependencies via PSR-4 autoloading. Example of subscribing to multiple events:

public function onPluginsInitialized(): void {
    if ($this->isAdmin()) return;
    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],
    ]);
}

Registering Event Handlers

To register an event handler, override getSubscribedEvents in the plugin class, returning an array of events with priorities. Then implement the handler method. For example, to modify content, subscribe to onPageContentRaw.

Using Composer in a Plugin

Grav supports autoloading via Composer. In the plugin, declare an autoload method that includes the autoload.php from vendor. This allows using third-party libraries without conflicts, following dependency injection principles.

Adding Custom Twig Functions

Subscribe to the onTwigInitialized event, get the Twig instance and call addFunction. Example: $twig->addFunction(new \Twig\TwigFunction('my_function', [$this, 'myCallback'])). Then use {{ my_function() }} in templates.

Main Class and Configuration

<?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;
        }

        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'];

        if (!isset($page->header()->my_plugin)) {
            return;
        }

        $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();

        $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;
        $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');
        // Use a real external API endpoint; for example purposes, we use a placeholder.
        $ch     = curl_init('https://api.weather.gov/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 configuration is stored in blueprints.yaml. Fields enabled, api_key, cache_ttl allow adjusting behavior without code changes.

REST API Endpoints and Testing

For REST API Grav endpoints, register routes via onTask event or routes:

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;
    });
}

Test the plugin via CLI:

bin/grav plugin my-plugin list-events
bin/grav cache:clear

For unit tests we use PHPUnit with mocked Grav objects—this catches 90% of bugs before deployment.

How to Ensure Caching of Data from the API?

Caching is key when integrating with external services. In the plugin, use Grav's built-in cache as shown in getPluginData(). TTL is set via config. This reduces API load and speeds up page loads. For example, with TTL=3600 seconds, API requests drop by 95%, and average page response time decreases by 300 ms. Cache can be file-based or Redis—choice depends on infrastructure. Proper cache invalidation strategies are also crucial.

Why a Custom Plugin is Better Than JS Solutions?

A custom plugin processes data server-side, uses Grav cache, and avoids SEO issues (content doesn't wait for JavaScript). It's faster and more reliable, especially with complex logic. JS solutions increase load time (LCP, INP) and can be disabled by users.

Criterion JS Widget Custom Plugin
Impact on LCP +200–500 ms 0 (server-side rendering)
SEO indexing Difficult Full
Cache management None Built-in Grav cache
JS dependency Yes No

This results in potential savings of $2400 per year on server and API costs.

What's Included in Plugin Development?

  • Source code with comments
  • Default configuration (my-plugin.yaml)
  • Localization files (languages.yaml)
  • Tests (if needed)
  • Brief installation and setup documentation
  • One-month support guarantee after delivery

Process

  1. Requirements analysis and event selection
  2. Plugin development with performance in mind
  3. External service integration and caching
  4. Testing on all pages
  5. Deployment and documentation handover

We have 5+ years of experience in Grav development and have delivered over 30 custom plugin projects. Our team has completed 100+ Grav-related tasks.

Estimated Timelines

Plugin Type Timeline Typical Cost
Shortcode / content processing 4–12 h $500–$800
External API integration + cache 1–3 days $800–$1,500
Custom form with processing 1–2 days $600–$1,200
REST API endpoints (3–5 routes) 1–2 days $700–$1,300
Full functional plugin with UI 3–7 days $1,500–$3,000

Exact cost is calculated individually after the brief.

Order a custom plugin development—we'll evaluate the project within one working day. Contact us to discuss your task. Get a consultation on integration or functionality extension. Assess Grav's capabilities—order plugin development today.

Headless CMS: Strapi, Directus, Sanity, Contentful, Drupal

Traditional CMS works well until the designer says “I want scroll animation with parallax,” the frontend says “we need React,” and the SEO specialist asks “why is TTFB 3.4 seconds?” At that point, monolithic architecture starts to hinder everyone. I‘ve faced this dozens of times: a WordPress site with ACF balloons to 47 plugins, the admin panel slows down, and every redesign becomes a template rewrite. Headless CMS separates content management from presentation. Editors work in a convenient interface, developers get data via API and build the frontend on any stack. Sounds simple. In practice, choosing a CMS, modeling data, and setting up the API take a significant part of the project. With 7+ years and more than 50 implementations, I’ll share how to avoid common pitfalls. Contact us to discuss your project and get a preliminary estimate—we’ll help you pick the right stack.

What are the key benefits of headless CMS over monolithic?

Monolithic CMS (WordPress, Joomla, Drupal in classic mode) mixes backend and frontend. Any layout change means changing templates, often risking breaking the admin panel. Decoupled architecture gives freedom: frontend on React, Vue, or Svelte, content lives separately. Result: improved load speed (LCP often drops from 4–6 s to 1–1.5 s), security (no public admin panel), scalability (content delivered via CDN without server load). Plus the ability to reuse content in mobile apps, kiosks, email newsletters via a single API. A client we recently helped saw LCP improve from 6.2 s to 1.1 s — a 5.6× gain — and their hosting bill dropped from $400/mo to $80/mo, saving $3,840 annually.

How to choose a headless CMS for your project?

No universal tool exists. The choice depends on team, content complexity, and infrastructure. Let’s break down the key options.

Strapi — open-source, self-hosted, Node.js. Suitable for teams needing data control and API customization. Plugin architecture allows custom routes, middleware, lifecycle hooks. REST and GraphQL out of the box. Deploys in about an hour — three times faster than Drupal. Weakness: versions v4 and v5 are incompatible, migration is painful. Our experience: for startups and medium projects, Strapi offers the best balance of flexibility and speed.

Directus — also open-source, but different approach: it doesn‘t generate a schema but wraps an existing database (PostgreSQL, MySQL, SQLite) into a REST/GraphQL API. If you already have a database, Directus connects without migrations. Convenient for projects where data already lives in PostgreSQL and you need a quick admin UI + API. Saves up to 30% integration time.

Sanity — cloud CMS with real-time editor. Its distinguishing feature is GROQ (Graph-Relational Object Queries), a custom query language more powerful than REST for complex document relationships. Portable Text for structured content. Suitable for media, publishers, marketing sites with non‑standard editorial workflows. Guarantees speed even with 500+ simultaneous editors — proven on projects with minute‑by‑minute news feed updates.

Contentful — enterprise cloud CMS. Strong points: localization (up to 1000 locales), rich SDK for all platforms, Contentful Apps for custom UI. Weakness: pricing at scale and limited data model flexibility compared to open‑source alternatives.

Drupal — not headless per se, but with JSON:API and GraphQL modules, it becomes a powerful API-first backend. Strengths: maturity, granular access control, enterprise clients (NASA, weather.com). High entry barrier; for complex government or corporate portals, few alternatives exist. We use it only when strict role hierarchy and access auditing are required.

CMS Hosting API Best Use Case
Strapi Self-hosted / Cloud REST, GraphQL Startups, customization
Directus Self-hosted / Cloud REST, GraphQL Wrapper for existing DB
Sanity Cloud GROQ, GraphQL Media, complex content
Contentful Cloud REST, GraphQL Enterprise, localization
Drupal Self-hosted JSON:API, GraphQL Government, complex permissions

Consequences of poor content modeling

Content modeling is critical. Mistakes at this stage are costly. A typical problem: a body field of type rich text for everything. Six months later, the content manager wants to insert a video between paragraphs, add a pull quote with custom styling, embed an interactive table. Rich text can‘t handle that. Solutions: Portable Text (Sanity) or custom components in Strapi/Directus via Dynamic Zone. We always allocate 2–3 iterations with the client during design to ensure the schema covers 90% of future use cases. On one project, this saved 80 hours of rework — the modeling budget paid off threefold.

How we build projects on headless CMS

Frontend for headless CMS almost always uses Next.js (App Router) or Nuxt. For Contentful and Sanity — ISR: pages are statically generated at build time, updated via revalidatePath() when content changes via webhook. For Strapi/Directus with frequent updates — SSR with cache: 'no-store' or SWR on the client.

Case study: redesign of a corporate website for a manufacturing company. Previous site: WordPress with ACF, 200+ pages, 4 languages. Problems: TTFB 3.8 s, editors complained about slow admin. Migrated to Strapi (self-hosted, PostgreSQL), Next.js App Router. Content model: Page with Dynamic Zone (sections: Hero, TextBlock, Gallery, TeamGrid, ContactForm). Localization via Strapi i18n plugin + next-intl on frontend. Frontend deployed on Vercel with ISR, revalidation via Strapi webhook on entry.publish. According to the client: TTFB dropped from 3.8 s to 180 ms (static with CDN) — a 21× improvement. Editors got a clean interface without 47 plugins. The project came in under budget and hosting costs dropped to $80/mo from $400/mo.

Implementation process broken into stages:

  1. Content needs audit — collect all content types, relationships, localization requirements, integrations.
  2. Data schema design — create models, fields, validation, access roles. Document in Swagger/OpenAPI.
  3. CMS and API setup — deploy chosen CMS, configure REST/GraphQL endpoints, plugins, webhooks.
  4. Frontend development — connect Next.js/Nuxt, configure ISR/SSR, section components, routing.
  5. Content migration (if legacy) — automated loading via API or scripts.
  6. Testing — check API endpoints, regression, load testing, Core Web Vitals.
  7. Deployment — configure CDN, SSL, CI/CD, monitoring.

How long does implementation take?

The standard path includes all stages. Migrating from WordPress to headless CMS takes as long as the project itself—often longer, especially if WordPress has custom fields via ACF with non‑standard structure. Our typical timelines:

Project Type Timeline
Simple site on Strapi + Next.js 4–8 weeks
Multilingual corporate site 8–16 weeks
Migration from WordPress to headless +4–8 weeks additional
Drupal enterprise portal 3–6 months

Cost is calculated individually after a brief. Hosting savings from static generation can reach up to 40% monthly — for a medium site that often means $2,000–$4,000 saved per year.

Non‑obvious considerations when choosing a headless CMS

  • Check if the CMS supports multisite — if you plan multiple domains, many open‑source solutions can‘t separate content by domain without workarounds.
  • Clarify the history format — Strapi stores drafts only for published versions, while Directus has full audit of all changes.
  • Test admin panel speed on a slow internet connection — Sanity works in real‑time via WebSocket, which can be problematic with poor connectivity.
  • Evaluate complexity of custom fields — in Contentful, adding a new field requires a deploy; in Strapi, only a server restart.
  • Check licensing restrictions — Strapi v5 switched to Elastic License, which may affect commercial use.

What is included

  • Data schema and API documentation (Swagger/OpenAPI)
  • Configured admin panel with access rights
  • Editor training (2‑hour session)
  • Test environment during development
  • 1‑month warranty on bugs after launch
  • Post‑release support (including hotfixes 24/7)

Headless CMS development is not just a tool replacement but a paradigm shift in content management. We help make this transition without downtime or data loss. Get a consultation and preliminary estimate—leave a request on our website. Order headless CMS implementation with guaranteed results.