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.
- 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.
- PHP ≥ 8.3
- Twig ≥ 3.21
Optional (Laravel integration): Laravel ^13,
rcrowe/twigbridge ^0.14,
spatie/laravel-data ^4 for Data components.
composer require ttbooking/twig-componentuse 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().
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']);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.
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'),
];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.
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.
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.
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 TestbenchNo database required.
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().
MIT.