Grav Custom Twig Extensions — Registering Filters and Functions
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
Not extending
AbstractExtension: Your extension class must extendTwig\Extension\AbstractExtension. Without this, Twig cannot register the filters and functions.Forgetting to enable the plugin on
onTwigExtensionsevent: TheonTwigExtensionsevent must be enabled in the plugin's subscriber list. Without it, the extension is never registered.Using static methods instead of instance methods: Twig extensions use instance methods. Define filters and functions as
[$this, 'methodName']not['ClassName', 'staticMethod'].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.
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
What event must a plugin subscribe to for registering Twig extensions? Answer: The
onTwigExtensionsevent. In the event handler, call$this->grav['twig']->twig->addExtension()with your extension instance.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.What methods return the list of filters, functions, and tests? Answer:
getFilters(),getFunctions(), andgetTests(). Each returns an array ofTwigFilter,TwigFunction, orTwigTestobjects.How do you access Grav services (config, cache, accounts) from an extension? Answer: Pass the
$gravobject to the extension constructor and store it as a property. The plugin'sonTwigExtensionsmethod can pass$this->gravto the extension.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
Mini Project
Goal: Build a complete custom Twig extension plugin for a documentation site.
- Create the plugin structure with proper namespace and event subscription
- 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)
- Add plugin configuration with customizable options
- Implement caching for expensive functions
- Add proper error handling with fallback values
- Gracefully handle missing dependencies (Guzzle HTTP client)
- Create usage examples for all extensions
- Test extensions in multiple templates
- Measure performance impact and optimize slow functions
- 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