---
url: /foehn-framework/guide/twig-extensions.md
---
# Twig Extensions

Føhn uses Timber which provides Twig as its templating engine. You can extend Twig with custom functions, filters, and more using the `#[AsTwigExtension]` attribute.

## Creating an Extension

Create a class that extends `Twig\Extension\AbstractExtension` and mark it with `#[AsTwigExtension]`:

```php
<?php

namespace App\Twig;

use Studiometa\Foehn\Attributes\AsTwigExtension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigFilter;

#[AsTwigExtension]
final class MyExtension extends AbstractExtension
{
    public function getFunctions(): array
    {
        return [
            new TwigFunction('hello', fn(string $name) => "Hello, {$name}!"),
        ];
    }

    public function getFilters(): array
    {
        return [
            new TwigFilter('reverse', fn(string $text) => strrev($text)),
        ];
    }
}
```

Use in templates:

```twig
{{ hello('World') }}        {# Hello, World! #}
{{ 'Foehn' | reverse }}     {# nheOf #}
```

## Adding Functions

Functions are called with {{ function\_name(args) }}:

```php
public function getFunctions(): array
{
    return [
        new TwigFunction('icon', [$this, 'renderIcon'], ['is_safe' => ['html']]),
        new TwigFunction('site_url', fn() => home_url()),
        new TwigFunction('current_year', fn() => date('Y')),
    ];
}

public function renderIcon(string $name, string $class = ''): string
{
    return sprintf(
        '<svg class="icon icon-%s %s"><use href="#icon-%s"></use></svg>',
        esc_attr($name),
        esc_attr($class),
        esc_attr($name)
    );
}
```

::: tip is\_safe option
Use `'is_safe' => ['html']` when your function returns HTML that should not be escaped.
:::

## Adding Filters

Filters transform values with {{ value | filter\_name }}:

```php
public function getFilters(): array
{
    return [
        new TwigFilter('excerpt', [$this, 'excerpt']),
        new TwigFilter('phone_link', [$this, 'phoneLink'], ['is_safe' => ['html']]),
        new TwigFilter('reading_time', [$this, 'readingTime']),
    ];
}

public function excerpt(string $text, int $length = 150): string
{
    $text = strip_tags($text);

    if (mb_strlen($text) <= $length) {
        return $text;
    }

    return mb_substr($text, 0, $length) . '…';
}

public function phoneLink(string $phone): string
{
    $clean = preg_replace('/[^0-9+]/', '', $phone);

    return sprintf('<a href="tel:%s">%s</a>', $clean, $phone);
}

public function readingTime(string $content): int
{
    $wordCount = str_word_count(strip_tags($content));

    return max(1, (int) ceil($wordCount / 200));
}
```

Usage:

```twig
<p>{{ post.content | excerpt(200) }}</p>
<p>{{ '+33 1 23 45 67 89' | phone_link }}</p>
<span>{{ post.content | reading_time }} min read</span>
```

## Dependency Injection

Extensions are resolved through the DI container:

```php
<?php

namespace App\Twig;

use App\Services\PriceFormatter;
use App\Services\ImageService;
use Studiometa\Foehn\Attributes\AsTwigExtension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;

#[AsTwigExtension]
final class ShopExtension extends AbstractExtension
{
    public function __construct(
        private readonly PriceFormatter $priceFormatter,
        private readonly ImageService $imageService,
    ) {}

    public function getFilters(): array
    {
        return [
            new TwigFilter('price', [$this->priceFormatter, 'format']),
        ];
    }

    public function getFunctions(): array
    {
        return [
            new TwigFunction('optimized_image', [$this->imageService, 'getOptimized']),
        ];
    }
}
```

## Priority

Control the order extensions are loaded with the `priority` parameter:

```php
#[AsTwigExtension(priority: 5)]
final class CoreExtension extends AbstractExtension
{
    // Loads first (lower priority = earlier)
}

#[AsTwigExtension(priority: 20)]
final class OverrideExtension extends AbstractExtension
{
    // Loads later, can override earlier definitions
}
```

## Project Structure

Organize your extensions by domain:

```
app/
└── Twig/
    ├── CoreExtension.php       # Basic utilities (icons, dates, etc.)
    ├── TextExtension.php       # Text manipulation filters
    ├── MediaExtension.php      # Image/video helpers
    └── ShopExtension.php       # E-commerce specific
```

## Built-in Extensions

Føhn includes useful extensions out of the box:

### TwigToolkitExtension

Føhn bundles [`studiometa/twig-toolkit`](https://github.com/studiometa/twig-toolkit) which provides essential helpers for HTML generation:

```twig
{# Conditional CSS classes #}
<div class="{{ html_classes([
    'card',
    {'card--featured': post.is_featured},
    {'card--large': size == 'large'}
]) }}">

{# Conditional inline styles #}
<div style="{{ html_styles({
    'color': text_color,
    'background': bg_color ?? false,
}) }}">

{# Render multiple attributes #}
<button {{ html_attributes({
    'class': 'btn',
    'disabled': is_disabled,
    'data-id': item.id,
}) }}>
```

The `{% element %}` tag allows dynamic element rendering:

```twig
{% element tag|default('div') with { class: 'wrapper' } %}
    Content here
{% endelement %}
```

| Function          | Description                         |
| ----------------- | ----------------------------------- |
| `html_classes`    | Build class attribute conditionally |
| `html_styles`     | Build style attribute conditionally |
| `html_attributes` | Render multiple HTML attributes     |
| `{% element %}`   | Dynamic element tag with attributes |

### InteractivityExtension

Helpers for WordPress Interactivity API:

```twig
<div
    {{ wp_interactive('theme/counter') }}
    {{ wp_context({ count: 0 }) }}
>
    <span {{ wp_text('context.count') }}>0</span>
    <button {{ wp_on('click', 'actions.increment') }}>+</button>
</div>
```

Available functions:

| Function         | Description                               |
| ---------------- | ----------------------------------------- |
| `wp_interactive` | Generate `data-wp-interactive` attribute  |
| `wp_context`     | Generate `data-wp-context` with JSON      |
| `wp_directive`   | Generate any `data-wp-*` directive        |
| `wp_bind`        | Generate `data-wp-bind--{attr}` directive |
| `wp_on`          | Generate `data-wp-on--{event}` directive  |
| `wp_class`       | Generate `data-wp-class--{class}`         |
| `wp_text`        | Generate `data-wp-text` directive         |

### VideoEmbedExtension

Video URL utilities:

```twig
{# Convert watch URL to embed URL #}
{{ 'https://youtube.com/watch?v=abc123' | video_embed }}
{# https://www.youtube.com/embed/abc123 #}

{# Extract video ID #}
{{ 'https://vimeo.com/123456789' | video_id }}
{# 123456789 #}

{# Detect platform #}
{{ 'https://youtube.com/watch?v=abc' | video_platform }}
{# youtube #}

{# Check if URL is supported #}
{% if url | video_is_supported %}
    <iframe src="{{ url | video_embed }}"></iframe>
{% endif %}
```

### QueryExtension

Helpers for URL query manipulation, useful for building filter UIs:

```twig
{# Reading parameters #}
{{ query_get('category') }}
{{ query_get('page', 1) }}              {# with default #}
{{ query_has('category') }}
{{ query_contains('tags', 'php') }}     {# value in array #}
{{ query_all() }}                       {# all params #}

{# Building URLs #}
{{ query_url({category: 'news'}) }}
{{ query_url_without('category') }}
{{ query_url_toggle('tags', 'php') }}   {# add/remove value #}
{{ query_url_clear() }}

{# Form helper #}
{{ query_hidden_inputs() | raw }}
{{ query_hidden_inputs(exclude=['s']) | raw }}
```

| Function              | Description                               |
| --------------------- | ----------------------------------------- |
| `query_get`           | Get parameter value with optional default |
| `query_has`           | Check if parameter exists                 |
| `query_contains`      | Check if value is in array parameter      |
| `query_all`           | Get all non-empty parameters              |
| `query_url`           | Build URL with modified parameters        |
| `query_url_without`   | Build URL without specified parameters    |
| `query_url_toggle`    | Toggle a value in a parameter             |
| `query_url_clear`     | Build URL without any parameters          |
| `query_hidden_inputs` | Generate hidden inputs for forms          |

See the [Query Filters Guide](/guide/query-filters) for complete examples.

### BlockMarkupExtension

Helpers for generating WordPress block comment syntax used in patterns and templates:

```twig
{# Opening and closing comments #}
{{ wp_block_start('heading', { level: 2 }) }}
<h2 class="wp-block-heading">Title</h2>
{{ wp_block_end('heading') }}

{# Or complete block in one call #}
{{ wp_block('paragraph', { align: 'center' }, '<p>Centered text</p>') }}

{# Nested blocks #}
{{ wp_block_start('group', { layout: { type: 'constrained' } }) }}
<div class="wp-block-group">
    {{ wp_block('heading', {}, '<h2>Section</h2>') }}
    {{ wp_block('paragraph', {}, '<p>Content here</p>') }}
</div>
{{ wp_block_end('group') }}
```

| Function         | Description                    |
| ---------------- | ------------------------------ |
| `wp_block_start` | Generate opening block comment |
| `wp_block_end`   | Generate closing block comment |
| `wp_block`       | Generate complete block markup |

## Complete Example

```php
<?php

namespace App\Twig;

use Studiometa\Foehn\Attributes\AsTwigExtension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;

#[AsTwigExtension]
final class ThemeExtension extends AbstractExtension
{
    public function getFunctions(): array
    {
        return [
            // SVG icon helper
            new TwigFunction('icon', [$this, 'icon'], ['is_safe' => ['html']]),

            // Asset URL helper
            new TwigFunction('asset', [$this, 'asset']),

            // Check if current page
            new TwigFunction('is_current', [$this, 'isCurrent']),
        ];
    }

    public function getFilters(): array
    {
        return [
            // Format phone numbers
            new TwigFilter('phone', [$this, 'formatPhone']),

            // Truncate text
            new TwigFilter('truncate', [$this, 'truncate']),

            // Format file size
            new TwigFilter('filesize', [$this, 'formatFilesize']),
        ];
    }

    public function icon(string $name, array $attrs = []): string
    {
        $class = 'icon icon-' . $name;

        if (isset($attrs['class'])) {
            $class .= ' ' . $attrs['class'];
            unset($attrs['class']);
        }

        $attrStr = '';

        foreach ($attrs as $key => $value) {
            $attrStr .= sprintf(' %s="%s"', $key, esc_attr($value));
        }

        return sprintf(
            '<svg class="%s"%s><use href="%s/dist/icons.svg#%s"></use></svg>',
            esc_attr($class),
            $attrStr,
            get_template_directory_uri(),
            esc_attr($name)
        );
    }

    public function asset(string $path): string
    {
        return get_template_directory_uri() . '/dist/' . ltrim($path, '/');
    }

    public function isCurrent(string $url): bool
    {
        $current = trailingslashit($_SERVER['REQUEST_URI'] ?? '');
        $check = trailingslashit(wp_parse_url($url, PHP_URL_PATH) ?? '');

        return $current === $check;
    }

    public function formatPhone(string $phone): string
    {
        // Format French phone: 0612345678 → 06 12 34 56 78
        $clean = preg_replace('/[^0-9]/', '', $phone);

        if (strlen($clean) === 10) {
            return implode(' ', str_split($clean, 2));
        }

        return $phone;
    }

    public function truncate(string $text, int $length = 100, string $suffix = '…'): string
    {
        $text = strip_tags($text);

        if (mb_strlen($text) <= $length) {
            return $text;
        }

        return mb_substr($text, 0, $length) . $suffix;
    }

    public function formatFilesize(int $bytes): string
    {
        $units = ['B', 'KB', 'MB', 'GB'];
        $i = 0;

        while ($bytes >= 1024 && $i < count($units) - 1) {
            $bytes /= 1024;
            $i++;
        }

        return round($bytes, 1) . ' ' . $units[$i];
    }
}
```

Usage in templates:

```twig
{# Icons #}
{{ icon('arrow-right') }}
{{ icon('menu', { class: 'w-6 h-6', 'aria-hidden': 'true' }) }}

{# Assets #}
<img src="{{ asset('images/logo.svg') }}" alt="Logo">

{# Navigation #}
<a href="/" class="{{ is_current('/') ? 'active' : '' }}">Home</a>

{# Filters #}
<a href="tel:{{ phone | phone }}">{{ phone | phone }}</a>
<p>{{ post.content | truncate(200) }}</p>
<span>{{ attachment.filesize | filesize }}</span>
```

## See Also

* [API Reference: #\[AsTwigExtension\]](/api/as-twig-extension)
* [Timber Documentation](https://timber.github.io/docs/v2/)
* [Twig Documentation](https://twig.symfony.com/doc/3.x/)
