Skip to content

Grav Custom Twig Extensions — Registering Filters and Functions

DodaTech Updated 2026-06-27 8 min read

In this tutorial, you'll learn how to create custom Twig extensions in Grav — registering custom filters, functions, and tests through plugins, extending Twig with PHP logic, and sharing extensions across themes.

What You'll Learn

  • What Twig extensions are and why to create them
  • Registering custom Twig filters in a plugin
  • Registering custom Twig functions in a plugin
  • Registering custom Twig tests
  • Using the onTwigExtensions() event
  • Best practices for organizing and naming extensions

Why It Matters

In Grav, you cannot run arbitrary PHP code in templates. This is by design — Twig is a sandboxed templating language. But sometimes you need logic that Twig cannot express: formatting currency with localization, querying an external API, Parsing a file, or calculating complex statistics. Twig extensions let you write PHP functions and expose them as Twig filters and functions. You get the power of PHP with the safety and readability of Twig.

Real-World Use

A documentation site needs to display download counts for each product. The counts are stored in a JSON file that updates hourly. Rather than writing JavaScript to fetch and render the counts, the team creates a Twig function download_count(slug) in a plugin. The function reads the JSON file and returns the count. Any template can call {{ download_count('grav') }} and display live download numbers.

Learning Path

flowchart LR
    A["Twig Debugging"] --> B["Custom Twig Extensions
← You are here"]:::current B --> C["Theme Configuration"] C --> D["Theme Assets"] D --> E["Theme Inheritance"] E --> F["Theme Languages"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

What Is a Twig Extension?

A Twig extension is a PHP class that registers custom filters, functions, and tests with Twig. Once registered, they work exactly like built-in Twig features.

| Twig Feature | Example Built-in | Custom Example | |-------------|-----------------|----------------| | Filter | {{ text|upper }} | {{ price|currency('USD') }} | | Function | {{ range(1, 5) }} | {{ random_quote() }} | | Test | {% if x is odd %} | {% if file is image %} |

Creating a Plugin with Twig Extensions

Step 1: Create the Plugin

# Create plugin directory
mkdir -p user/plugins/custom-twig/custom-twig.php

Step 2: Plugin Registration

user/plugins/custom-twig/custom-twig.php:

<?php
namespace Grav\Plugin;

use Grav\Common\Plugin;
use Grav\Plugin\CustomTwig\CustomTwigExtension;

class CustomTwigPlugin extends Plugin
{
    public static function getSubscribedEvents()
    {
        return [
            'onPluginsInitialized' => ['onPluginsInitialized', 0],
        ];
    }

    public function onPluginsInitialized()
    {
        if ($this->isAdmin()) {
            return;
        }

        $this->enable([
            'onTwigExtensions' => ['onTwigExtensions', 0],
        ]);
    }

    public function onTwigExtensions()
    {
        require_once __DIR__ . '/classes/CustomTwigExtension.php';
        $this->grav['twig']->twig->addExtension(
            new CustomTwigExtension($this->grav)
        );
    }
}

Step 3: Create the Extension Class

user/plugins/custom-twig/classes/CustomTwigExtension.php:

<?php
namespace Grav\Plugin\CustomTwig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;

class CustomTwigExtension extends AbstractExtension
{
    protected $grav;

    public function __construct($grav)
    {
        $this->grav = $grav;
    }

    public function getName()
    {
        return 'custom_twig_extension';
    }

    public function getFilters()
    {
        return [
            new TwigFilter('currency', [$this, 'formatCurrency']),
            new TwigFilter('readable_filesize', [$this, 'readableFilesize']),
            new TwigFilter('truncate_words', [$this, 'truncateWords']),
        ];
    }

    public function getFunctions()
    {
        return [
            new TwigFunction('random_quote', [$this, 'getRandomQuote']),
            new TwigFunction('active_users', [$this, 'getActiveUsers']),
            new TwigFunction('api_data', [$this, 'fetchApiData']),
        ];
    }

    public function getTests()
    {
        return [
            new TwigTest('image', [$this, 'isImageFile']),
            new TwigTest('video', [$this, 'isVideoFile']),
        ];
    }

    public function formatCurrency($amount, $currency = 'USD')
    {
        $symbols = ['USD' => '$', 'EUR' => '€', 'GBP' => '£', 'INR' => '₹'];
        $symbol = $symbols[$currency] ?? '$';
        return $symbol . number_format($amount, 2);
    }

    public function readableFilesize($bytes)
    {
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $i = 0;
        while ($bytes >= 1024 && $i < 4) {
            $bytes /= 1024;
            $i++;
        }
        return round($bytes, 2) . ' ' . $units[$i];
    }

    public function truncateWords($text, $limit = 50)
    {
        $words = explode(' ', strip_tags($text));
        if (count($words) <= $limit) {
            return $text;
        }
        return implode(' ', array_slice($words, 0, $limit)) . '...';
    }

    public function getRandomQuote()
    {
        $quotes = [
            'The best way to predict the future is to invent it.',
            'Simplicity is the soul of efficiency.',
            'Code is like humor. When you have to explain it, it is bad.',
        ];
        return $quotes[array_rand($quotes)];
    }

    public function getActiveUsers($hours = 24)
    {
        // Query user accounts from Grav
        $accounts = $this->grav['accounts']->all();
        $active = [];
        foreach ($accounts as $username => $account) {
            if (isset($account['last_login']) &&
                $account['last_login'] > time() - ($hours * 3600)) {
                $active[] = $username;
            }
        }
        return $active;
    }

    public function fetchApiData($url, $cacheMinutes = 10)
    {
        $cache = $this->grav['cache'];
        $cacheId = 'api_' . md5($url);

        if ($cached = $cache->fetch($cacheId)) {
            return $cached;
        }

        $client = new \GuzzleHttp\Client();
        try {
            $response = $client->get($url, ['timeout' => 5]);
            $data = json_decode($response->getBody(), true);
            $cache->save($cacheId, $data, $cacheMinutes * 60);
            return $data;
        } catch (\Exception $e) {
            return null;
        }
    }

    public function isImageFile($filename)
    {
        $extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
        $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
        return in_array($ext, $extensions);
    }

    public function isVideoFile($filename)
    {
        $extensions = ['mp4', 'webm', 'ogg', 'avi', 'mov'];
        $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
        return in_array($ext, $extensions);
    }
}

Using Custom Extensions in Templates

{# Custom filters #}
{{ 199.99|currency('USD') }}
{# Output: $199.99 #}

{{ 2048576|readable_filesize }}
{# Output: 1.95 MB #}

{{ page.content|truncate_words(20) }}
{# Output: First 20 words with ellipsis... #}

{# Custom functions #}
<blockquote>{{ random_quote() }}</blockquote>

Active users (24h): {{ active_users()|join(', ') }}

{% set data = api_data('https://api.github.com/repos/getgrav/grav') %}
Stars: {{ data.stargazers_count }}

{# Custom tests #}
{% if filename is image %}
    <img src="{{ filename }}" alt="" />
{% elseif filename is video %}
    <video src="{{ filename }}"></video>
{% endif %}

Extension Configuration

Add configuration for your extension:

user/plugins/custom-twig/custom-twig.yaml:

enabled: true
currency_symbols:
    USD: $
    EUR:     GBP: £
    INR: api_cache_minutes: 10

Access configuration in the extension:

public function formatCurrency($amount, $currency = 'USD')
{
    $symbols = (array)$this->grav['config']->get(
        'plugins.custom-twig.currency_symbols',
        ['USD' => '$']
    );
    $symbol = $symbols[$currency] ?? '$';
    return $symbol . number_format($amount, 2);
}

Global Extensions vs Per-Theme Extensions

Approach Scope Best For
Plugin extension Site-wide Filters and functions used in multiple themes
Theme extension Theme-specific Extensions tied to a specific theme

Register a theme-specific extension in the theme's PHP file:

<?php
// user/themes/mytheme/mytheme.php
namespace Grav\Theme;

use Grav\Common\Theme;

class Mytheme extends Theme
{
    public function onTwigExtensions()
    {
        require_once __DIR__ . '/classes/ThemeExtension.php';
        $this->grav['twig']->twig->addExtension(
            new \Grav\Theme\Mytheme\ThemeExtension()
        );
    }
}

Learning Path

flowchart LR
    A["Twig Debugging"] --> B["Custom Twig Extensions
← You are here"]:::current B --> C["Theme Configuration"] C --> D["Theme Assets"] D --> E["Theme Inheritance"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

Common Mistakes

  1. Not extending AbstractExtension: Your extension class must extend Twig\Extension\AbstractExtension. Without this, Twig cannot register the filters and functions.

  2. Forgetting to enable the plugin on onTwigExtensions event: The onTwigExtensions event must be enabled in the plugin's subscriber list. Without it, the extension is never registered.

  3. Using static methods instead of instance methods: Twig extensions use instance methods. Define filters and functions as [$this, 'methodName'] not ['ClassName', 'staticMethod'].

  4. Not Caching API results: Functions that call external APIs should cache results. Without caching, every page load triggers an API call, slowing down the site.

  5. Ignoring error handling in extension code: If your extension throws an exception, it breaks the entire page. Wrap external calls in try-catch blocks and return safe fallback values.

Practice Questions

  1. What event must a plugin subscribe to for registering Twig extensions? Answer: The onTwigExtensions event. In the event handler, call $this->grav['twig']->twig->addExtension() with your extension instance.

  2. What PHP class should custom Twig extensions extend? Answer: Twig\Extension\AbstractExtension. It provides the base methods that Twig uses to discover filters, functions, and tests.

  3. What methods return the list of filters, functions, and tests? Answer: getFilters(), getFunctions(), and getTests(). Each returns an array of TwigFilter, TwigFunction, or TwigTest objects.

  4. How do you access Grav services (config, cache, accounts) from an extension? Answer: Pass the $grav object to the extension constructor and store it as a property. The plugin's onTwigExtensions method can pass $this->grav to the extension.

  5. Challenge: Build a comprehensive Twig extension plugin with at least 5 custom filters and 5 custom functions. Filters should include: obfuscate email (rot13 encoding), pluralize (returns singular or plural based on count), time_ago (returns "3 hours ago" style strings), excerpt (first N paragraphs), and highlight (wraps search terms in <mark> tags). Functions should include: grav_version (returns current Grav version), page_tree (returns nested page structure), file_list (returns files from a directory), weather (fetches weather for a city), and git_info (returns last commit data for a file). Register the extension via plugin, test all filters and functions in templates, and ensure proper error handling and caching.

FAQ

What is the difference between a Twig filter and a Twig function?

A filter transforms a value (pipe syntax: {{ value|filter }}). A function generates a value (function call: {{ function() }}). Some operations can be implemented as either — choose based on readability.

Can I register Twig extensions from a theme instead of a plugin?

Yes. Create a onTwigExtensions() method in the theme's PHP class file. Theme extensions are available only when the theme is active, while plugin extensions are site-wide.

How do I pass multiple arguments to a custom filter?

Filters receive the filtered value as the first argument, followed by additional arguments: new TwigFilter('myfilter', [$this, 'myFilter']) and the PHP method receives ($value, $arg1, $arg2).

Do custom Twig extensions affect performance?

The extension registration adds minimal overhead per request. The performance impact comes from the PHP logic inside the filter or function. Cache expensive operations and optimize database/API calls.

Can I override an existing Twig filter with a custom one?

Yes, but it is not recommended. If you register a filter with the same name as a built-in filter, your version takes precedence. Name your custom filters uniquely to avoid confusion.

Mini Project

Goal: Build a complete custom Twig extension plugin for a documentation site.

  1. Create the plugin structure with proper namespace and event subscription
  2. Create a Twig extension class with: 3 string filters (obfuscate_email, highlight_terms, excerpt), 2 number filters (filesize, duration), 2 array filters (shuffle, pluck), 3 functions (grav_version, page_breadcrumbs, file_tree), and 2 tests (pdf, code_file)
  3. Add plugin configuration with customizable options
  4. Implement caching for expensive functions
  5. Add proper error handling with fallback values
  6. Gracefully handle missing dependencies (Guzzle HTTP client)
  7. Create usage examples for all extensions
  8. Test extensions in multiple templates
  9. Measure performance impact and optimize slow functions
  10. Document the extension API for other developers

What's Next

Now you can extend Twig with custom PHP logic. Next, learn theme configuration:

Continue to Lesson 20: Theme Configuration — Blueprints.yaml, theme.yaml, form fields, and theme settings.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro