offsetwp / twig-bundle
Twig integration for the OffsetWP Framework: zero configuration to start, every option when you need it
Requires
- php: >=8.5
- offsetwp/framework: ^1.1
- psr/container: ^1.1 || ^2.0
- symfony/config: ^8.1
- symfony/dependency-injection: ^8.1
- twig/twig: ^3.27
Requires (Dev)
- league/commonmark: ^2.10
- lorenzo/pinky: ^1.1
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.3
- symfony/cache: ^8.1
- symfony/css-selector: ^8.1
- symfony/filesystem: ^8.1
- symfony/intl: ^8.1
- symfony/mime: ^8.1
- symfony/string: ^8.1
- symfony/translation-contracts: ^3.7
- symfony/yaml: ^8.1
- tijsverkoyen/css-to-inline-styles: ^2.4
- twig/cache-extra: ^3.24
- twig/cssinliner-extra: ^3.26
- twig/html-extra: ^3.24
- twig/inky-extra: ^3.24
- twig/intl-extra: ^3.26
- twig/markdown-extra: ^3.26
- twig/string-extra: ^3.24
- wp-coding-standards/wpcs: ^3.4.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-09-21 07:10:51 UTC
README
OffsetWP Twig Bundle
Twig, integrated into the OffsetWP Framework. Zero configuration to start, every option when you need it.
- πΏ Zero configuration β a working Twig out of the box
- ποΈ Every option β the full Twig environment surface, with Twig's own defaults
- π·οΈ Attributes first β a filter is one method and one attribute
- π€ Lazy by construction β nothing unused is ever instantiated
- π§© Extend anything β extensions, filters, functions, tests, tags, globals, loaders
- π¦ Works with every
twig/*-extrapackage, with no change to this bundle - π§ Errors that name the file, the service or the key at fault
Installation
requirements:
- PHP: 8.5+
command:
composer require offsetwp/twig-bundle
register bundle:
// config/bundles.php return array( \OffsetWP\Bundle\TwigBundle\TwigBundle::class => array( 'all' => true ), );
Rendering a template
Where each file goes:
my-project/
ββ app/
β ββ Twig/ # your filters, extensions, loaders β ordinary classes
β ββ PriceExtension.php
ββ config/
β ββ bundles.php # where this bundle is registered
β ββ services.php # where your classes become services
β ββ packages/
β ββ twig.php # optional β every key of the reference below
ββ templates/ # the default directory, searched with no configuration
ββ pages/
β ββ front-page.twig # rendered as 'pages/front-page.twig'
ββ emails/
ββ welcome.twig # rendered as 'emails/welcome.twig'
The twig() helper
One token, no use statement, available anywhere after the kernel booted β a theme file, a
plugin, a mu-plugin. It hands back the environment itself, a real Twig\Environment, so
every method Twig documents is one step away:
twig()->render( 'pages/front-page.twig', array( 'name' => 'JΓ©rΓ΄me' ) ); twig()->display( 'pages/front-page.twig', array( 'name' => 'JΓ©rΓ΄me' ) ); twig()->addGlobal( 'site', get_bloginfo( 'name' ) ); // before the first render twig()->getLoader(); twig()->getCharset(); twig()->isDebug();
Given a template name it renders and returns the string, which is the one shorthand it adds:
// front-page.php echo twig( 'pages/front-page.twig', array( 'name' => get_bloginfo( 'name' ) ) );
The Twig facade
The same environment, without touching the global namespace:
use OffsetWP\Bundle\TwigBundle\Twig; Twig::environment()->render( 'pages/front-page.twig', array( 'name' => 'JΓ©rΓ΄me' ) ); Twig::environment()->addGlobal( 'site', get_bloginfo( 'name' ) ); Twig::environment()->getLoader();
With two shorthands on top of it, and one question to ask before either:
echo Twig::render( 'pages/front-page.twig', array( 'name' => 'JΓ©rΓ΄me' ) ); Twig::display( 'pages/front-page.twig' ); // Would a shortcut call find Twig? Answered without risking the failure it asks about. if ( Twig::booted() ) { /* β¦ */ }
From the container
$kernel->service( 'twig' )->render( 'emails/welcome.twig' );
The same object the helper hands back, with no facade and no global.
Several kernels in one request
A mu-plugin and a theme can each boot a kernel in one request. Each compiles its own container and owns its own environment, sharing nothing β not even a compiled template. The shortcut designates the last kernel that booted. Name one when that is not what you want:
$kernel = Kernel::configure( __DIR__ )->config( __DIR__ . '/config' )->boot(); Twig::of( $kernel )->render( 'emails/welcome.twig' ); $kernel->service( 'twig' )->render( 'emails/welcome.twig' ); // the same object
A kernel that never registered this bundle, or has not booted, is refused by name β its own root path is in the message.
Template paths
Zero configuration and the default directory
templates/ at the root of the project is searched with no configuration at all, and only
when it exists:
echo twig( 'pages/front-page.twig' ); // templates/pages/front-page.twig
Adding paths
One call per directory, each under a namespace or under the main one:
// config/packages/twig.php use OffsetWP\Bundle\TwigBundle\Configuration\TwigConfig; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return static function ( ContainerConfigurator $container ): void { TwigConfig::create() ->path( '%kernel.root_path%/templates/emails', 'emails' ) ->path( '%kernel.root_path%/vendor-views', 'shop' ) // The main namespace is searched in the order written, and templates/ comes // last β so this one answers first for a filename they share. ->path( '/abs/legacy-templates' ) ->apply( $container ); };
%kernel.root_path% is the root of the project, and is resolved before this bundle sees the
value β in a YAML file as readily as in a PHP one. Any other absolute path works too. A
relative one is refused when the container builds, and so is a directory that does not exist.
Namespaced paths
{% include '@emails/welcome.twig' %}
{% extends '@shop/layout.twig' %}
An unknown namespace raises Twig's own error, which names the namespaces that are defined.
Configuration reference
Every key below goes in one file, under twig. The sections around this one show each key
in place; this is the list of all of them.
// config/packages/twig.php use OffsetWP\Bundle\TwigBundle\Configuration\Escaping; use OffsetWP\Bundle\TwigBundle\Configuration\TwigConfig; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return static function ( ContainerConfigurator $container ): void { TwigConfig::create() ->strictVariables( true ) ->autoescape( Escaping::ByTemplateName ) ->apply( $container ); };
One method per key, so an editor completes the whole surface and a mistyped key is a compile error. It invents no default and validates nothing: what it builds goes through the same tree as the array below, and raises the same messages.
That array is the native form, and the one every example in the rest of this file could have been written in:
// config/packages/twig.php use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return static function ( ContainerConfigurator $container ): void { $container->extension( 'twig', array( 'strict_variables' => true, 'autoescape' => 'name', ) ); };
The kernel reads config/packages/*.yaml as readily, if you composer require symfony/yaml.
A YAML file has no methods to call, so it writes the keys:
# config/packages/twig.yaml twig: strict_variables: true autoescape: name
The full table
Eighteen keys: two template directories, then every option Twig\Environment's
constructor accepts, then globals and extensions, then six settings of Twig's core
extension. Every default is Twig's own except the two marked.
| Key | Type | Default | Where the default comes from |
|---|---|---|---|
paths |
array<string, ?string> β absolute directory β namespace |
array() |
β |
default_path |
string |
%kernel.root_path%/templates |
this bundle |
debug |
bool |
%kernel.is_debug% |
this bundle β Twig's own default is false |
charset |
string |
%kernel.charset% |
the kernel β whose own default is UTF-8, the value Twig uses |
strict_variables |
bool |
false |
Twig |
autoescape |
false|string|callable |
'html' |
Twig |
cache |
false|string|'@service_id' |
false |
Twig |
auto_reload |
?bool |
null |
Twig β null means "follow debug" |
optimizations |
int |
-1 |
Twig β -1 all on, 0 all off |
use_yield |
bool |
false |
Twig |
globals |
array<string, mixed> β name β value, @service or @@literal |
array() |
β |
extensions |
list<class-string> |
array() |
β |
date.format |
string |
'F j, Y H:i' |
Twig's core extension |
date.interval_format |
string |
'%d days' |
Twig's core extension |
date.timezone |
?string |
null β PHP's own default |
Twig's core extension |
number_format.decimals |
int |
0 |
Twig's core extension |
number_format.decimal_point |
string |
'.' |
Twig's core extension |
number_format.thousands_separator |
string |
',' |
Twig's core extension |
An unknown key, a value of the wrong type and a boolean key written with nothing after it are each refused when the container builds.
debug and charset
These two follow the kernel rather than Twig: charset takes %kernel.charset%, whose own
default is UTF-8 β Twig's value β and debug takes %kernel.is_debug%, so a project
booted with ->debug( true ) gets template names and line numbers in its errors. Set either
one and yours is used.
auto_reload follows debug unless you say otherwise. Set it to false to keep the
diagnostics of debug mode without Twig stat-ing every template on every request:
->debug( true ) ->autoReload( false )
autoescape
false, the name of any strategy Twig knows, 'name' to pick one from each template's name,
or a PHP callable:
->autoescape( Escaping::Html ) // the default ->autoescape( Escaping::Js ) ->autoescape( Escaping::ByTemplateName ) // .txt.twig renders raw, everything else as HTML ->autoescape( 'a_strategy_you_registered' ) ->noAutoescape() // escape nothing ->autoescapeWith( EscapingStrategy::class, 'guess' )
Escaping names the six strategies Twig ships β Html, Js, Css, Url, HtmlAttribute,
HtmlAttributeRelaxed β plus ByTemplateName.
// app/Twig/EscapingStrategy.php final class EscapingStrategy { public static function guess( string $name ): string|false { return str_ends_with( $name, '.txt.twig' ) ? false : 'html'; } }
Two strings rather than a callable: every extension configuration is serialised while the container compiles, and a closure cannot be.
cache
Twig's compilation cache β where Twig writes the PHP class it compiles each template into. This bundle passes the key through and creates no directory, writes no file and ships no cache warmer.
->noCache() // the default ->cacheDirectory( '%kernel.root_path%/var/cache/twig' ) ->cacheDirectory( '%env(TWIG_CACHE_DIR)%' ) // resolved while the container compiles ->cacheService( 'app.twig_cache' ) // a service implementing Twig\Cache\CacheInterface
A directory has to be absolute, and a relative one is refused when the container builds. An
environment variable works here and in path() and defaultPath(); its value is substituted
after this bundle has run, so the directory is Twig's to check at the first render.
date and number formats
Settings of Twig's core extension rather than constructor options, so that a site needing a
comma and Europe/Paris has them on its first day:
->dateFormat( 'd/m/Y H:i' ) ->dateIntervalFormat( '%d jours' ) // a literal percent sign is written %% ->dateTimezone( 'Europe/Paris' ) ->numberDecimals( 2 ) ->numberDecimalPoint( ',' ) ->numberThousandsSeparator( ' ' )
{{ post.published_at|date }} {# 01/01/2026 13:00 #}
{{ 1234.5|number_format }} {# 1 234,50 #}
timezone left at null follows PHP's own. One PHP does not recognise is refused when the
container builds. The container reads %name% as one of its own parameters, so the canonical
interval format %H:%I:%S is written %%H:%%I:%%S.
use_yield
->useYield( true )
A compiled template then yields its output instead of building the whole string in memory,
which matters for a page that streams. Everything Twig ships is ready for it; a custom node
of yours has to yield rather than return, and carry #[YieldReady] β see
Adding a tag.
Adding a filter, a function or a test
With attributes
A public method and one attribute. No interface, no base class, no registration of its own:
// app/Twig/PriceExtension.php namespace App\Twig; use Twig\Attribute\AsTwigFilter; use Twig\Attribute\AsTwigFunction; use Twig\Attribute\AsTwigTest; final class PriceExtension { public function __construct( private CurrencyRates $rates ) { } // Not static: compiles to a call through Twig's runtime loader, so this class and // its dependency are built the first time a rendered template reaches the filter, // and never otherwise. #[AsTwigFilter( name: 'price' )] public function price( int $cents, string $currency = 'EUR' ): string { return $this->rates->format( $cents, $currency ); } #[AsTwigFunction( name: 'rate' )] public function rate( string $currency ): float { return $this->rates->rate( $currency ); } // Static: compiles to a direct call, and constructs nothing at all. Use one when the // method needs nothing from the object. #[AsTwigTest( name: 'free' )] public static function isFree( int $cents ): bool { return 0 === $cents; } }
{{ 1250|price }} {# 12,50 EUR #}
{{ rate( 'USD' ) }} {# 1.1 #}
{% if product.cents is free %}Free{% endif %}
Nothing registers that class with Twig. Your services file does, once, for every class in the namespace β and the same file covers a filter, a function, a test, an extension, a tag and a loader alike:
// config/services.php use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return static function ( ContainerConfigurator $container ): void { $services = $container->services(); $services ->defaults() ->autowire() ->autoconfigure(); // this is what reads the attributes $services->load( 'App\\', '../app/*' ); };
With autoconfigure() off, the same class works with a tag written by hand:
$services->set( PriceExtension::class ) ->tag( 'twig.attribute_extension' ) ->tag( 'twig.runtime' );
An attributed method has to be public. A private one is refused when the container builds, naming the class and the method.
Receiving the environment
Type-hint the first parameter and Twig passes it rather than expecting it from the template:
#[AsTwigFunction( name: 'charset' )] public function charset( Environment $environment ): string { return $environment->getCharset(); }
The attributes repeat, so one implementation can answer to two names:
#[AsTwigFilter( name: 'shout' )] #[AsTwigFilter( name: 'yell' )] public function shout( string $text ): string { return strtoupper( $text ) . '!'; }
Adding a full extension
By interface
Implement Twig's extension interface β usually by extending AbstractExtension β and nothing
else is needed:
// app/Twig/ShopExtension.php namespace App\Twig; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; final class ShopExtension extends AbstractExtension { public function getFilters(): array { return array( new TwigFilter( 'vat', array( $this, 'vat' ) ) ); } public function vat( float $amount, float $rate = 0.2 ): float { return $amount * ( 1 + $rate ); } }
By tag, and priorities
Without autoconfiguration, or to control the order, tag it:
$services->set( ShopExtension::class )->tag( 'twig.extension', array( 'priority' => 10 ) );
Higher priority reaches Twig first, the default is 0, and ties keep the order you declared.
Twig lets the last declaration of a filter name win, so the lower priority is the one
whose implementation survives β which is the knob for overriding a filter. Attributes and
extensions are ordered against each other, and at equal priority a class carrying attributes
is the one Twig receives last.
By configuration
An extension class that takes no constructor argument needs no service declaration at all, only a line in the configuration file:
// config/packages/twig.php use OffsetWP\Bundle\TwigBundle\Configuration\TwigConfig; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Twig\Extension\DebugExtension; use Twig\Extra\Intl\IntlExtension; return static function ( ContainerConfigurator $container ): void { TwigConfig::create() ->extension( IntlExtension::class ) ->extension( DebugExtension::class ) ->apply( $container ); };
->extensions( IntlExtension::class, DebugExtension::class ) says the same in one call.
A class that does not exist, that is not a Twig extension, or that arrives both here and as a tagged service, is refused when the container builds β and that last message names both sources.
Adding a tag
A tag is one class for the parser and one for the node. Nothing is registered anywhere.
The token parser
// app/Twig/TokenParser/GreetTokenParser.php namespace App\Twig\TokenParser; use App\Twig\Node\GreetNode; use Twig\Node\Node; use Twig\Token; use Twig\TokenParser\AbstractTokenParser; final class GreetTokenParser extends AbstractTokenParser { // Parses {% greet <expression> %}. public function parse( Token $token ): Node { $name = $this->parser->parseExpression(); $this->parser->getStream()->expect( Token::BLOCK_END_TYPE ); return new GreetNode( $name, $token->getLine() ); } public function getTag(): string { return 'greet'; } }
The node
// app/Twig/Node/GreetNode.php namespace App\Twig\Node; use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Node; // #[YieldReady] says this node hands its output back rather than printing it. A node // written today should carry it: it costs nothing with use_yield off, and it is what the // option needs when it is on. #[YieldReady] final class GreetNode extends Node { public function __construct( AbstractExpression $name, int $line ) { parent::__construct( array( 'name' => $name ), array(), $line ); } public function compile( Compiler $compiler ): void { $compiler ->addDebugInfo( $this ) ->write( 'yield "Hello, " . ' ) ->subcompile( $this->getNode( 'name' ) ) ->raw( ";\n" ); } }
{% greet user.first_name %}
Node visitors
A node visitor reaches compilation the same way, by its interface alone:
// app/Twig/UppercaseNodeVisitor.php namespace App\Twig; use Twig\Environment; use Twig\Node\Node; use Twig\Node\TextNode; use Twig\NodeVisitor\NodeVisitorInterface; final class UppercaseNodeVisitor implements NodeVisitorInterface { public function enterNode( Node $node, Environment $environment ): Node { return $node; } // Returning null here would remove the node from the tree. public function leaveNode( Node $node, Environment $environment ): ?Node { return $node; } public function getPriority(): int { return 0; } }
Twig sorts visitors by the priority they report themselves. Two reporting the same one are
decided by the order they were added, which is the twig.node_visitor tag priority.
Globals
A variable every template can read without being handed it:
// config/packages/twig.php use App\Service\MenuBuilder; use OffsetWP\Bundle\TwigBundle\Configuration\TwigConfig; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; return static function ( ContainerConfigurator $container ): void { TwigConfig::create() ->global( 'year', 2026 ) // a scalar ->global( 'site_name', '%app.name%' ) // a container parameter ->globalService( 'menu', MenuBuilder::class ) // a service ->globalLiteral( 'handle', '@offsetwp' ) // the string, "@" and all ->apply( $container ); };
{{ site_name }} β {{ year }}
A global naming a service that does not exist is refused when the container builds, naming the global and the service id.
A global that is a service is built eagerly, on every request that renders anything at all β Twig wants the value before its extensions initialise. When that matters, expose a function instead: it is called only by the templates that use it, and its class is constructed only then.
// app/Twig/Menu.php final class Menu { #[AsTwigFunction( name: 'menu' )] public function menu(): array { return $this->builder->build(); } }
{% for item in menu() %}β¦{% endfor %}
Custom loaders
One loader
Implement Twig's loader interface β a loader is one class, like every other way into Twig:
// app/Twig/DatabaseLoader.php namespace App\Twig; use Twig\Loader\LoaderInterface; final class DatabaseLoader implements LoaderInterface { /** β¦ getSourceContext, getCacheKey, isFresh, exists β¦ */ }
With autoconfiguration on, nothing else is needed. Register it under an id of your own β its
class name does the job; Twig\Loader\LoaderInterface is the id this bundle points at
whichever loader it settles on, and a loader registered there is refused.
With exactly one loader registered it is used directly, with no chain around it.
Several loaders and the chain
The built-in filesystem loader carries priority 0. Give yours a higher one and it is
consulted first, with the filesystem as the fallback:
$services->set( DatabaseLoader::class )->tag( 'twig.loader', array( 'priority' => 10 ) );
Replacing the filesystem loader
The filesystem loader exists only when it has somewhere to look. Configure no paths and
keep no templates/ directory, and yours is the only loader there is:
// config/packages/twig.php β no path, so no filesystem loader TwigConfig::create()->apply( $container );
// config/services.php β no templates/ directory in the project $services->set( DatabaseLoader::class );
With no paths, no templates/ directory and no loader of your own, the environment still
builds and the first render names the directory it looked for.
The twig/*-extra packages
None of these is special-cased: this bundle holds no list of them, no class_exists() probe
and no recipe. Each is an extension class like any other, and a package published tomorrow
works the same way. A unit test asserts that no file under src/ mentions any of their
names.
Summary
| Package | What it gives you | Needs a runtime | Also pulls in |
|---|---|---|---|
twig/intl-extra |
format_currency, format_date, format_number, country_name, β¦ |
no | the intl component, and PHP's intl extension at runtime |
twig/string-extra |
u, slug, plural, β¦ |
no | the string component, translation contracts |
twig/html-extra |
html_classes, data_uri |
no | the mime component |
twig/cssinliner-extra |
inline_css |
no | tijsverkoyen/css-to-inline-styles |
twig/inky-extra |
inky_to_html |
no | lorenzo/pinky |
twig/markdown-extra |
markdown_to_html, html_to_markdown |
yes | nothing β you pick the Markdown library |
twig/cache-extra |
{% cache %} |
yes | the cache component |
Five install in one line. The other two ship a runtime whose constructor takes an argument no autowiring can guess, so they take three.
intl, string, html, cssinliner and inky
composer require twig/intl-extra
// config/packages/twig.php ->extension( \Twig\Extra\Intl\IntlExtension::class )
{{ 1234.5|format_currency( 'EUR', {}, 'fr_FR' ) }} {# 1 234,50 β¬ #}
The other four are the same single line with their own class name:
{{ 'Un ΓtΓ©'|slug }} {# Un-Ete #}
{{ html_classes( 'a', { 'b': true, 'c': false } ) }} {# a b #}
{% apply inline_css %}β¦{% endapply %}
{% apply inky_to_html %}β¦{% endapply %}
intl needs PHP's intl extension enabled on the server, which Composer cannot install for
you β check php -m | grep intl before blaming a template.
markdown
composer require twig/markdown-extra league/commonmark
// config/services.php use Twig\Extra\Markdown\DefaultMarkdown; use Twig\Extra\Markdown\MarkdownExtension; use Twig\Extra\Markdown\MarkdownRuntime; $services->set( MarkdownExtension::class )->tag( 'twig.extension' ); $services->set( DefaultMarkdown::class ); $services->set( MarkdownRuntime::class ) ->args( array( service( DefaultMarkdown::class ) ) ) ->tag( 'twig.runtime' );
{{ '# Titre'|markdown_to_html }} {# <h1>Titre</h1> #}
Three lines rather than one. MarkdownRuntime takes an argument no autowiring can guess β
which Markdown implementation you want β and implements no interface, so its
->tag( 'twig.runtime' ) is not optional. Without it the filter compiles and then finds
nothing to call:
Unable to load the "Twig\Extra\Markdown\MarkdownRuntime" runtime in "post.twig" at line 1.
DefaultMarkdown picks whichever Markdown library is installed, and names what to install
when none is:
You cannot use the "markdown_to_html" filter as no Markdown library is available;
try running "composer require league/commonmark".
The sibling filter html_to_markdown is static and needs no runtime, but does need
composer require league/html-to-markdown.
cache
composer require twig/cache-extra symfony/cache
// config/services.php use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter; use Twig\Extra\Cache\CacheExtension; use Twig\Extra\Cache\CacheRuntime; $services->set( 'app.twig_cache', FilesystemTagAwareAdapter::class ) ->args( array( 'twig', 0, '%kernel.root_path%/var/cache/twig-fragments' ) ); $services->set( CacheExtension::class )->tag( 'twig.extension' ); $services->set( CacheRuntime::class ) ->args( array( service( 'app.twig_cache' ) ) ) ->tag( 'twig.runtime' );
{% cache "sidebar" ttl( 300 ) %}β¦{% endcache %}
Three lines again, and for the same reasons: CacheRuntime takes a tag-aware cache pool β
which is what lets you point it at Redis without asking anyone β and implements no interface,
so its twig.runtime tag is written by hand.
This is not Twig's cache option. The names invite the confusion and the two have
nothing to do with each other:
twig.cache |
{% cache %} |
|
|---|---|---|
| caches | compiled templates | rendered output |
| comes from | Twig itself | twig/cache-extra |
| configured in | config/packages/twig.php |
config/services.php |
| invalidated by | the template changing | the ttl, or a tag |
Either can be on while the other is off.
Any other package
The rule is the shape of what the package ships:
- an extension class with no constructor argument β add it to
extensions; - an extension class with constructor arguments β declare it as a service, tag it
twig.extension; - a companion runtime class β declare it as a service, tag it
twig.runtime, whether or not it implements Twig's runtime interface.
Extensions Twig ships but this bundle does not enable
Two extensions Twig ships are not loaded here: this package adds nothing Twig itself would not add. Both are one line.
The dump extension
->extension( \Twig\Extension\DebugExtension::class )
{{ dump( post ) }}
Not enabled by debug, tempting as that is.
The string loader extension
->extension( \Twig\Extension\StringLoaderExtension::class )
{{ include( template_from_string( post.meta.layout ) ) }}
Renders a template held in a string rather than in a file β which is what you want when the template lives in the database, and exactly why it is opt-in.
Bundles that provide their own templates
A bundle ships templates by prepending a namespaced path into this extension. Prepending merges with whatever the host configured; it does not replace it.
// src/ShopBundle.php namespace App\Bundle; use OffsetWP\Bundle\TwigBundle\Configuration\TwigConfig; use OffsetWP\Framework\Bundle\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; final class ShopBundle extends Bundle { public function prependExtension( ContainerConfigurator $container, ContainerBuilder $builder ): void { TwigConfig::create() ->path( __DIR__ . DIRECTORY_SEPARATOR . 'templates', 'shop' ) ->apply( $container, prepend: true ); } }
{% include '@shop/cart.twig' %}
__DIR__ and not %kernel.root_path%: the directory wanted here is the bundle's own, and
that parameter holds the host's.
The host keeps its own templates/ directory and its own paths, and every namespace
reaches the loader.
Troubleshooting
Template not found
Twig names every directory it searched:
Unable to find template "pages/front-page.twig" (looked into: /abs/templates).
If the list is empty or missing a directory you expected, the paths you wrote never
reached the loader. If the message is instead
Twig has no template source. The default directory "/abs/templates" does not exist and no
"twig.paths" entry or "twig.loader" service is configured. Create that directory, or
configure "twig.paths".
then nothing provides templates at all: create that directory, or configure twig.paths.
My filter is not found
Unknown "price" filter means the class carrying the attribute never reached Twig. In
order of likelihood:
- The class is not a registered service. Attributes on a class the container has never
heard of do nothing, and nothing can report it. Check that your
config/services.phploads the namespace it lives in. autoconfigureis off in your_defaults. Without it neither the attributes nor the interfaces are scanned. Everything still works through explicit tags.- The method is not public. That one is refused when the container builds, with a message naming the class and the method β so if you are seeing "unknown filter" instead, this is not it.
My class is never instantiated, or is instantiated too early
Never instantiated is usually correct: a class behind an attribute is built on the first template call that reaches it, and never otherwise.
Too early has one usual cause: a globals entry pointing at a service, which Twig wants
before its extensions initialise. Expose a function instead β see Globals.
Twig ignores my configuration
A build saying a service of this bundle is defined by your project as well means your own
config/services.php redefined one of its ids, and everything the twig configuration had
put on it β options, paths, globals, extensions β went with it. The message names the id.
Configure twig instead, or register your own service under an id of your own.
Nothing happens at all
The kernel was not built in configuration mode, so it registered no bundles β see
Installation. twig() in that state raises "No kernel carrying the Twig
bundle has booted", and names both reasons a kernel that did boot can be carrying none: a
config/bundles.php that does not enable this bundle for the environment, and a kernel built
the other way.
Performance notes
The framework recompiles its container on every request. With Twig's compilation cache off as
well, a page render recompiles both β so setting cache is the single highest-impact change
a production site can make here:
->cacheDirectory( '%kernel.root_path%/var/cache/twig' )
Nothing else in this package is eager: booting constructs nothing, building the environment constructs no extension behind an attribute, and a request that renders no template touches none of it.
Licence
MIT. See LICENSE.

