Symfony UX TwigComponent: Building Reusable Blocks
AI generated
SF
{ }
Symfony · UX · TwigComponent · PHP · Design System
Symfony UX TwigComponent:
Building Reusable Blocks the Professional Way

Twig templates that keep copying the same alert boxes, card layouts and button variants quickly turn into a maintenance burden. Symfony UX TwigComponent brings a real component architecture to Twig, with props, slots and PHP backing classes for logic, all within the Symfony ecosystem.

16 min read Anonymous Components · Props · Slots · Backing Classes · Tailwind Symfony 7.x · symfony/ux-twig-component · PHP 8.3+

1. Why TwigComponent instead of Twig include

The classic Twig include splits templates into partials that get embedded wherever needed. That works well for simple cases, but it hits its limits as soon as a component needs logic. A button that automatically calls its own route, a card that reads data from the current context, or an alert that knows several variants (success, error, warning), all of that requires either a pile of with parameters or logic in the calling template when using include. Symfony UX TwigComponent solves this with a clean component architecture: props define the input, an optional PHP class holds the logic, and slots enable flexible content embedding.

The difference from Twig macros: macros are pure template functions with no access to the Twig context and no PHP backing. They cannot inject services, run database queries, or contain PHP logic. TwigComponents are full PHP classes that receive Symfony services via constructor injection, using the exact same dependency injection container as controllers and services. A breadcrumb component can read the current path directly from the request stack. A product card can calculate its price with the pricing service. That makes TwigComponents real building blocks for a scaling frontend architecture in Symfony.

2. Installation and folder structure

Installing symfony/ux-twig-component happens via Composer. The Flex recipe registers the bundle and creates the default configuration. Component templates live by default in templates/components/, PHP backing classes in src/Twig/Components/. The convention links class and template automatically: the class App\Twig\Components\Alert uses the template templates/components/Alert.html.twig. Custom paths can be adjusted in the bundle configuration.

For namespace hierarchies within the component system, you create subfolders: templates/components/Form/Input.html.twig corresponds to the component name Form:Input. That allows a logical grouping of form components, layout components and UI components. PHP classes follow the same hierarchy: App\Twig\Components\Form\Input. In large projects with a full design system, subfolders are indispensable, without them templates/components/ quickly turns into a confusing pile of dozens of flat files.


<?php

declare(strict_types=1);

namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

/**
 * Alert component with configurable type and dismissible behavior.
 * Used as: <twig:Alert type="success" dismissible>Meldung</twig:Alert>
 */
#[AsTwigComponent]
final class Alert
{
    /**
     * The visual type of the alert: success, error, warning, or info.
     * Determines the background color and icon shown.
     */
    public string $type = 'info';

    /**
     * Whether the user can dismiss this alert via a close button.
     */
    public bool $dismissible = false;

    /**
     * Maps type to Tailwind CSS classes for background and border.
     * Exposed to the template via getTypeClasses().
     */
    #[ExposeInTemplate]
    public function getTypeClasses(): string
    {
        return match ($this->type) {
            'success' => 'bg-green-50 border-green-400 text-green-800',
            'error'   => 'bg-red-50 border-red-400 text-red-800',
            'warning' => 'bg-yellow-50 border-yellow-400 text-yellow-800',
            default   => 'bg-blue-50 border-blue-400 text-blue-800',
        };
    }
}

3. Anonymous components: pure template blocks

Not every TwigComponent needs a PHP class. Anonymous components are Twig templates in templates/components/ that have no backing class. They receive props via Twig variables and are a good fit for purely structural UI building blocks with no logic: card container, divider, badge, avatar. They are called with the HTML-like syntax: <twig:Card title="Product">Content</twig:Card>. This syntax feels intuitive to teams coming from React or Vue, components look like HTML elements and are used directly inside templates.

Anonymous components define their props via the {% props %} tag at the top of the template: {% props title, variant = 'default', class = '' %}. Passed values are immediately available as Twig variables. Unknown props, props that are not declared in the {% props %} tag, end up in the special attributes variable, which contains all non-declared HTML attributes. That allows passing through HTML attributes such as id, data-* and ARIA attributes to the component's root element without declaring them explicitly. This feature is comparable to $attrs in Vue or ...rest in React components.

4. Props: type-safe input for components

In PHP backing classes, props are defined as public properties of the class. The full PHP type system and Symfony validation are available. A public string $type = 'info' is a string prop with a default value. A public bool $dismissible = false is a boolean prop. Symfony's serializer system takes care of type conversion, strings coming from the Twig template are converted automatically into the correct PHP types. For complex types such as enums, dedicated hydrators are available.

PHP 8.1 enums are an ideal fit as prop types for TwigComponent. An AlertType enum with the values Success, Error, Warning and Info makes the allowed prop values explicit in code, PHPStan and IDE autocompletion immediately flag an invalid value being passed. That is considerably safer than a string prop, where typos only surface at runtime. Hydrating string values from Twig templates into enum instances is handled automatically by the TwigComponent package whenever the prop type is a BackedEnum.

5. PHP backing classes for logic and services

The key advantage of PHP backing classes in Symfony UX TwigComponent is full Symfony DI integration. Every class can receive services via constructor injection, exactly like a controller or a repository. A breadcrumb component injects the router and the request stack to determine the current URL and build up the breadcrumb structure automatically. A product card injects a pricing service to calculate the user-specific price directly inside the component.

Methods of the backing class marked with #[ExposeInTemplate] are available directly as variables in the Twig template. A method getTypeClasses(): string is accessible in the template as {{ typeClasses }}, with no explicit method call needed. That keeps the Twig template clean of PHP logic and enables unit testing the backing class without an HTTP layer. The component can be tested in isolation: set props, call methods, check return values. That is a major advantage over Twig macros or complex include constructs, which are not directly testable.


<?php
{# templates/components/Alert.html.twig #}
{# This template is automatically linked to App\Twig\Components\Alert #}
<div class="border-l-4 p-4 rounded {{ typeClasses }}" role="alert">
  <div class="flex items-start gap-3">
    {# Icon based on type, rendered server-side, no JS needed #}
    {% if type == 'success' %}
      <svg class="w-5 h-5 mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
      </svg>
    {% endif %}

    {# The default slot, content between <twig:Alert>...</twig:Alert> tags #}
    <div class="flex-1 text-sm font-medium">{{ content }}</div>

    {# Conditional dismiss button, only rendered when dismissible=true #}
    {% if dismissible %}
      <button type="button" class="ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex items-center justify-center h-8 w-8 opacity-70 hover:opacity-100">
        <span class="sr-only">Close</span>
        <svg class="w-3 h-3" fill="none" viewBox="0 0 14 14">
          <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
        </svg>
      </button>
    {% endif %}
  </div>
</div>

{# Usage in any Twig template:
   <twig:Alert type="success" :dismissible="true">
     Your order was placed successfully!
   </twig:Alert>
   <twig:Alert type="error">
     Please check your input.
   </twig:Alert>
#}

6. Slots: embedding flexible content in components

Slots are named areas within a TwigComponent that the calling code can fill with arbitrary content. The default slot is the Twig construct {{ content }}, everything placed between the opening and closing component tags becomes available as content. For components with several customizable areas, for example a card with a header, body and footer, you define named slots using the {% block %} pattern of the TwigComponent slots system.

A concrete example: a Modal component has a title slot, a content slot and a footer slot with action buttons. The calling code fills these slots with arbitrary Twig content without needing to know or duplicate the modal's underlying structure. The modal template is defined once, every modal window in the application looks identical and stays consistent, changes to the modal design only need to happen in one place. This pattern mirrors the slot concept from Vue, Web Components and Svelte, fully within the Twig ecosystem in Symfony TwigComponent.

7. Tailwind CSS integration and variants

Tailwind CSS and Symfony UX TwigComponent fit together very well. Component variants are driven by Tailwind classes produced by PHP logic in the backing class. The class set for a button with the variants primary, secondary and danger is computed by a getClasses() method that translates the prop value into Tailwind class strings. That keeps the class logic out of the template and makes it unit testable.

An important aspect when combining Tailwind with dynamic classes: Tailwind scans source code for class strings at build time. Dynamically assembled strings such as 'bg-' ~ color ~ '-500' are not found and get purged from the CSS. The correct pattern is therefore to write full class strings in PHP code or Twig templates, never to assemble them. In the backing class you use a match expression that returns complete class strings: 'bg-green-500 text-white hover:bg-green-600'. The Tailwind scanner finds these complete strings and keeps them in the CSS output. That is the exact same pattern known from React Tailwind integration, in TwigComponent it works identically.

8. Building a design system with TwigComponent

A complete design system built with Symfony UX TwigComponent consists of three layers: atom components (Button, Badge, Icon, Input, Avatar), molecule components (AlertBanner, Card, FormGroup, Dropdown), and organism components (Header, Sidebar, DataTable, Modal). Atom components are mostly anonymous components without a backing class, they are pure template blocks with props. Molecule components often have simple backing classes containing variant logic. Organism components can inject services and prepare complex data.

Consistent naming follows the pattern DesignSystem:Atom:Button for the component name, which appears in the Twig template as <twig:DesignSystem:Atom:Button>. That is far more expressive than generic names and prevents naming conflicts in large teams. For design system documentation within the project, a simple Twig template that shows every component in all its variants is enough, Storybook-like, but entirely within Twig and PHP. Symfony UX TwigComponent turns maintaining such a design system into a PHP task with no separate frontend tooling.

9. TwigComponent vs. Twig include vs. Twig macro

A direct comparison of the three approaches to Twig reuse shows when each tool is the best choice.

Criterion Twig Include Twig Macro TwigComponent
Inject PHP services Not possible Not possible Yes, constructor DI
Embed slot content Not native Limited via caller() Native, named slots
Twig context Full access No Twig context Own scope + props
Unit testing possible Not isolated Not isolated Backing class testable
HTML-like syntax {% include %} {{ macros.fn() }} <twig:Alert>

For simple template partials without logic, include remains a legitimate and sufficient choice. For reusable UI blocks with variants and flexible content, the anonymous component is the right choice. For components that need services or compute complex output, the PHP backing class is indispensable. TwigComponent does not fully replace Twig includes and macros, it complements them for the area where a component architecture is genuinely needed.

Mironsoft

Symfony frontend architecture, TwigComponent and design system development

Need a design system and UI components for your Symfony project?

We build complete design systems with Symfony UX TwigComponent, from atom components and Tailwind variants through PHP backing classes to design system documentation for your team.

Component library

Button, Card, Alert, Modal, form elements as TwigComponents with Tailwind variants for a consistent UI

Backing class architecture

PHP logic and service injection in TwigComponents, unit testable, maintainable, Symfony-native

Design system audit

Migrate existing Twig templates to TwigComponent and turn them into a maintainable design system

10. Summary

Symfony UX TwigComponent brings a real component architecture to the Twig ecosystem. Anonymous components without PHP backing are a good fit for pure template blocks with props and slots. PHP backing classes enable service injection, logic encapsulation and unit testing. The slots system allows flexible content embedding for generic layout components. Tailwind CSS variants are driven by match expressions in the backing class, complete class strings that the Tailwind scanner can find.

The biggest win lies in consistency and maintainability. When ten templates copy the same alert block and the design changes, ten places need to be updated. With a single Alert TwigComponent, it is one place. New variants, accessibility improvements and design updates propagate automatically through every place that uses the component. Combined with Live Component for reactive variants and Turbo for navigation, this results in a complete, maintainable frontend system, entirely in PHP and Twig with no separate JavaScript framework.

Symfony UX TwigComponent, the essentials at a glance

Anonymous components

Twig template in templates/components/ with no PHP class. Props via {% props %}. Default slot via {{ content }}. Ideal for pure UI blocks with no logic.

PHP backing classes

#[AsTwigComponent] on the PHP class. Constructor DI for services. #[ExposeInTemplate] makes methods accessible as Twig variables, unit testable.

Slots

Default slot via {{ content }}. Named slots for header, body, footer. Usage: <twig:Modal><twig:block name="footer">...</twig:block></twig:Modal>.

Tailwind variants

match expression returns complete class strings, never assemble strings. The Tailwind scanner finds complete strings at build time.

11. FAQ: Symfony UX TwigComponent and reusable blocks

1What is symfony/ux-twig-component?
Reusable Twig components with props, slots and optional PHP backing classes. HTML-like syntax: <twig:Alert type="success">. Part of the Symfony UX ecosystem.
2Anonymous vs. backing class?
Anonymous: pure Twig template with no PHP, for UI blocks with no logic. Backing class: PHP with DI, logic and unit tests. Both need a Twig template file.
3Defining props?
Anonymous: {% props title, variant = 'default' %}. Backing class: public PHP properties. Enums as prop types are hydrated automatically.
4Injecting services?
Constructor property promotion in the backing class. Symfony DI injects automatically, exactly like in controllers. #[AsTwigComponent] on the class is mandatory.
5What are slots?
Default slot: {{ content }}, content between the component tags. Named slots via <twig:block name="footer"> for several customizable areas.
6The ExposeInTemplate attribute?
#[ExposeInTemplate] on a method makes it accessible in Twig as a variable. getTypeClasses() becomes {{ typeClasses }}, no explicit method call in the template.
7Using Tailwind classes correctly?
Never assemble strings, always use complete classes in match expressions. The Tailwind scanner finds complete strings. Dynamically assembled classes go missing from the CSS output.
8Unit testing backing classes?
Yes. Normal PHP class, testable without an HTTP layer or Twig. Mock services, set props, call methods. Not possible with Twig include or macros.
9Organizing a design system?
Subfolders following Atomic Design: components/Atom/, components/Molecule/, components/Organism/. Component name: <twig:Atom:Button>. PHP classes in a mirrored structure.
10TwigComponent or Twig include?
Simple partials with no variants: include is sufficient. UI blocks with props and variants: anonymous component. Logic and services: backing class. Not everything needs to be a TwigComponent.