ttbooking / twig-component
Class components and slots for Twig: {% component %} and {% slot %} tags, auto-discovery registry with a manifest cache. Framework-free core + optional Laravel integration.
Requires
- php: ^8.3
- psr/container: ^1.1 || ^2.0
- symfony/finder: ^6.4 || ^7.0
- twig/twig: ^3.21
Requires (Dev)
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^11.5 || ^12.0
- rcrowe/twigbridge: ^0.14
- spatie/laravel-data: ^4.0
Suggests
- laravel/framework: Laravel integration (^13): widget DI via the container, rendering via view(), manifest artisan commands
- rcrowe/twigbridge: Twig <-> Laravel views bridge (^0.14), required for the Laravel integration
- spatie/laravel-data: Presentational Data components (only together with Laravel, ^4.0)
README
Class components and slots for Twig: the {% component %} tag, the component() function,
Vue 3 style {% slot %} slots and an auto-discovery component registry with a manifest cache.
The core is framework-neutral — it runs on bare Twig in any PHP application.
The Laravel integration (DI through the container, rendering via view(), artisan commands)
is an optional layer; integrating with another framework means implementing two small interfaces.
Features
- Components as classes. Widgets (your own class with logic + DI) and — in Laravel —
presentational components on
spatie/laravel-data(props only). - Tag and function.
{% component 'name' with {...} %}…{% endcomponent %}and inlinecomponent('name', {...}). - Vue 3 style slots. A default slot (the tag body) + named
{% slot 'name' %}with a<slot>fallback in the component template. The slot body executes in the caller's scope. - Auto-discovery. Components are found by namespace/path; the name is derived from the
class by convention (
App\View\TwigComponents\UI\Box→ui:box). The manifest is cached for production. - Integration points.
ComponentFactory(how to instantiate a component) andTemplateRenderer(how to render a template) — nothing else touches the framework.
Requirements
- PHP ≥ 8.3
- Twig ≥ 3.21
Optional (Laravel integration): Laravel ^13,
rcrowe/twigbridge ^0.14,
spatie/laravel-data ^4 for Data components.
Installation
composer require ttbooking/twig-component
Without a framework (bare Twig)
use TTBooking\TwigComponent\{ComponentExtension, ComponentRegistry, NativeComponentFactory, TwigTemplateRenderer}; use Twig\Environment; use Twig\Loader\FilesystemLoader; $registry = new ComponentRegistry( namespace: 'App\\View\\TwigComponents\\', componentsPath: __DIR__.'/src/View/TwigComponents', manifestPath: __DIR__.'/var/cache/twig-component.php', ); $twig = new Environment(new FilesystemLoader(__DIR__.'/templates')); $twig->addExtension(new ComponentExtension( $registry, new NativeComponentFactory($psr11Container), // container is optional: without it — props-only constructors new TwigTemplateRenderer($twig), // also accepts a closure fn (): Environment ));
NativeComponentFactory instantiates widgets via new with named arguments;
if any PSR-11 container is provided, constructor parameters without a default are autowired
from it by type. Warm the manifest during deployment: $registry->cache().
Laravel
The ServiceProvider is registered via package discovery. Publish the config and enable
the Twig extension in config/twigbridge.php:
php artisan vendor:publish --tag=twig-component-config
// config/twigbridge.php 'extensions' => [ 'enabled' => [ // … TTBooking\TwigComponent\ComponentExtension::class, ], ],
The provider binds the Laravel implementations itself: widgets are built by the container
(app($class, $props)), templates render through view(), Data components are available.
A component can also be rendered directly from PHP — handy in tests of application components; no Twig environment is needed for that:
$html = app(ComponentExtension::class)->renderComponent('ui:box', ['title' => 'Orders']);
Another framework
Implement ComponentFactory (instantiating a component from props using your DI)
and, if needed, TemplateRenderer (when rendering must not go directly through Twig) —
then assemble ComponentExtension as in the example above.
Configuration (Laravel)
config/twig-component.php — where to look for components and where to write the manifest:
return [ 'namespace' => 'App\\View\\TwigComponents\\', 'path' => app_path('View/TwigComponents'), 'manifest' => base_path('bootstrap/cache/twig-component.php'), ];
Quick start
A widget component:
namespace App\View\TwigComponents\UI; use TTBooking\TwigComponent\TwigComponent; class Box implements TwigComponent { public function __construct( public string $title = '', public bool $collapsible = false, ) {} public function template(): string { // a name the active renderer understands: // standalone — a Twig template path: return 'components/ui/box.html.twig'; // Laravel — a view name (view()->name() enables IDE navigation): return view('components/ui/box')->name(); } public function context(): array { return []; } }
The components/ui/box.html.twig template (this is the component instance, slots are the passed slots):
<div class="box"> <div class="box-header"> {% slot 'header' %}<h3>{{ this.title }}</h3>{% endslot %} </div> <div class="box-body">{% slot %}{% endslot %}</div> </div>
Usage at the call site:
{% component 'ui:box' with { title: 'Orders' } %}
<p>The body content is the default slot.</p>
{% slot 'header' %}<h3>A custom heading instead of the fallback</h3>{% endslot %}
{% endcomponent %}
A presentational component (data only, Laravel integration) extends
Spatie\LaravelData\Data; props are available in the template directly. Outside Laravel this
kind is unavailable — a limitation of laravel-data itself; use widgets instead.
Manifest cache
The component registry is cached into a flat manifest. In Laravel (wired into optimize):
php artisan twig-component:cache # build (part of php artisan optimize) php artisan twig-component:clear # remove (part of optimize:clear)
Without a framework — $registry->cache() / $registry->clearCache() in your deploy script.
How slots work
A component renders in a separate Twig pass, so the slot body is captured into a string
and injected into the component template rather than being bound with native {% embed %}.
Consequences: slots render eagerly, there are no scoped slots.
Rules (modeled on Vue 3):
- A top-level
{% slot 'name' %}inside{% component %}passes a named slot (like<template #name>); all remaining body content is the default slotcontent. - The default slot can also be passed explicitly:
{% slot %}…{% endslot %}without a name (like<template #default>). Combining an explicit default slot with loose content is a template compilation error; passing the same slot twice is one too. - A body of nothing but whitespace/newlines (formatting around
{% slot %}) does not count as the default slot — the{% slot %}fallback in the component template is preserved. - A
{% slot %}deeper than the top level (e.g. inside{% if %}) is not slot passing but a "hole with a fallback" in the current template's scope. In a component template this lets you forward your slots into a nested component; on a regular page there is noslotsand such a tag simply renders its fallback in place. - The
thisandslotskeys of the render context are reserved: acontext()key (or a Data component prop — Data props become the context) with such a name raises an error instead of a silent override.
Tests
composer install vendor/bin/phpunit # both suites vendor/bin/phpunit --testsuite Core # the core on bare Twig, no framework vendor/bin/phpunit --testsuite Laravel # the integration layer on Orchestra Testbench
No database required.
Documentation
Detailed guides live in the docs/ folder (Russian translation: docs/ru/):
- Getting started — installation, standalone bootstrap, your first component.
- Components — widgets, props and
this,context(), DI, thecomponent()function, the naming convention. - Slots — default and named slots, passing rules, forwarding into a nested component.
- Laravel — ServiceProvider, config, Data components, artisan commands, rendering in tests.
- Recipes — ready-made examples: a box with a slot, a modal with named slots, a select with logic in
context().
License
MIT.