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.

Maintainers

Package info

github.com/ttbooking/twig-component

pkg:composer/ttbooking/twig-component

Transparency log

Statistics

Installs: 43

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.5.0 2026-08-06 06:35 UTC

This package is auto-updated.

Last update: 2026-08-06 06:36:40 UTC


README

Tests Latest Stable Version Total Downloads PHP Version License

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 inline component('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\Boxui:box). The manifest is cached for production.
  • Integration points. ComponentFactory (how to instantiate a component) and TemplateRenderer (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 slot content.
  • 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 no slots and such a tag simply renders its fallback in place.
  • The this and slots keys of the render context are reserved: a context() 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, the component() 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.