Symfony UX Twig Components

repository·3.x·Indexed 42 days ago

https://github.com/symfony/ux-twig-component

A Symfony UX component that allows developers to bind PHP objects to Twig templates to create reusable, encapsulated UI units. It supports both class-based components with dependency injection and anonymous components, featuring a robust system for props, HTML attributes, and content blocks.

Tokens
11.8K
Snippets
35
Records
38
Agent score
53%

What's inside Twig Components

  1. Use Computed Properties for lazy loading and caching

    3.x

    To keep components lazy, avoid heavy logic in the constructor. Instead, use methods to fetch data.

    If you call a method directly in Twig (e.g., {{ this.products }}), it will execute every time it is called. To implement true computed properties (where the result is cached for the duration of the render), use the computed proxy.

    Important: Do NOT use #[ExposeInTemplate] on a computed method, as this will cause the method to be called twice (once for the attribute and once for the template), defeating the purpose of the cache.

    {# templates/components/FeaturedProducts.html.twig #}
    <div>
        <h3>Featured Products</h3>
    
        {% for product in computed.products %}
            ... 
        {% endfor %}
    
        {# This uses the cached value and does not trigger a second query #}
        {% for product in computed.products %}
            ... 
        {% endfor %}
    </div>
  2. Handle HTML attributes in Twig Components

    3.x

    Components can accept arbitrary HTML attributes that are not mapped to component properties. These are collected into a ComponentAttributes object available as attributes within the component template. This allows you to pass standard HTML attributes like class, style, or id directly to the component's root element.

    Key Behaviors:

    • Boolean Attributes: Setting an attribute to true renders the attribute name with an empty value (e.g., autofocus=""). Note that aria-* and data-* attributes are exceptions and will render as the string "true".
    • Excluding Attributes: Setting an attribute to false or null excludes it from the output (except for aria-* attributes, where false renders as the string "false").
    • Stimulus Integration: You can add Stimulus controllers to the root element using the stimulus_controller() function (requires symfony/stimulus-bundle).
    • Customizing the Variable Name: You can change the name of the attributes variable using the #[AsTwigComponent(attributesVar: '_attributes')] attribute on the component class.
    {# templates/components/MyComponent.html.twig #}
    <div {{ attributes }}>
      My Component!
    </div>
    
    {# Rendering the component with attributes #}
    <twig:MyComponent class="foo" style="color: red" />
  3. Create Anonymous Components

    3.x

    If a component is simple and doesn't require a PHP class, you can create an 'anonymous component' by providing only a Twig template. The component name is derived from its file path within the templates/components/ directory.

    • Subdirectories: A template at templates/components/Button/Primary.html.twig is named Button:Primary.
    • Index files: If a component lives in a directory with the same name, you can name the file index.html.twig to simplify the name. For example, templates/components/Menu/index.html.twig is simply named Menu.

    Anonymous components support passing extra attributes (which are rendered on the element) and props (variables passed into the template).

    {# templates/components/Button/Primary.html.twig #}
    <button {{ attributes.defaults({class: 'primary'}) }}>
        {% block content %}{% endblock %}
    </button>
    
    {# Usage #}
    <twig:Button:Primary>Click Me!</twig:Button:Primary>
  4. Create Higher-Order Components (Component Wrappers)

    3.x

    You can create a component that wraps another component to extend its markup, behavior, or structure without modifying the original. This is often called a "Higher-Order Component" (HOC) or "Component Wrapper".

    To implement a wrapper:

    1. Use the spread operator {{ ...attributes }} to pass attributes from the wrapper to the wrapped component.
    2. Use {{ block(outerBlocks.content) }} to forward content blocks from the wrapper to the wrapped component.
    3. The wrapper can define its own {% props %} to add new functionality.
    # templates/components/Modal.html.twig
    <div{{ attributes.defaults({class: 'modal'}) }}>
        <div class="modal-content">
            {% block content %}{% endblock %}
        </div>
    </div>
    
    # templates/components/Modal/Confirm.html.twig
    {% props confirmText = 'Confirm', cancelText = 'Cancel' %}
    
    <twig:Modal {{ ...attributes.defaults({class: 'modal-confirm'}) }}>
        {{ block(outerBlocks.content) }}
    
        <div class="modal-actions">
            <button type="button" class="btn-secondary">{{ cancelText }}</button>
            <button type="submit" class="btn-primary">{{ confirmText }}</button>
        </div>
    </twig:Modal>
  5. What are Twig Components

    3.x
    Twig Components allow you to bind a PHP object to a Twig template. This enables you to create small, reusable template 'units' (such as alerts, modals, or sidebars) that encapsulate both logic and markup. By binding an object to a template, you can easily pass data and state into these reusable UI elements.
  6. Share state between components using provide() and inject()

    3.x

    When a component is composed of multiple nested pieces, you can share data from a parent to any descendant without manually passing props through every level using the provide() and inject() pattern.

    How it works:

    1. provide(key, value): Used in the parent component to publish a value. Keys should be unique (e.g., componentName.propertyName) to avoid collisions.
    2. inject(key, default): Used in any descendant to retrieve the value. If the key is not found, it returns the provided default.

    Rules:

    • Direction: Values flow top-down only. Children cannot push values up to parents.
    • Scope: Values are available to descendants during the current render cycle. Once the parent finishes rendering, the provided values are dropped. Sibling components do not share state.
    • Placement: provide() must be called at the top of the parent template, before any blocks that contain descendants, to ensure the value is available when the descendant attempts to inject() it.
    {# Parent: templates/components/InputOtp.html.twig #}
    {% props maxLength = 6 %}
    {% do provide('inputOtp.maxLength', maxLength) %}
    
    <div class="input-otp">
        {% block content %}{% endblock %}
    </div>
    
    
    {# Descendant: templates/components/InputOtp/Slot.html.twig #}
    {% props input %}
    {% set maxLength = inject('inputOtp.maxLength', 4) %}
    
    <input type="text" maxlength="1" data-index="{{ input }}" data-max-length="{{ maxLength }}" />
  7. Understand variable scope and `outerScope`

    3.x

    When passing content into a component, the content lives in an independent template that extends the component's template. This creates specific scoping rules:

    • this variable: Inside a component's content block, this refers to the component currently being rendered (the 'inner' component).
    • Variable Merging: Variables from the parent template are available inside the component's content. However, if the component defines a prop with the same name, the component's prop takes precedence.
    • outerScope: To access variables or the this context from higher up in the template hierarchy (the 'outer' scope), use the outerScope variable. You can chain these (e.g., outerScope.outerScope.this) to reach even higher levels.
    • Block Variable Access: A unique feature is that code inside a block has access to the variables of the template where the block was defined (as if it were copy-pasted).
    {# Accessing outer variables #}
    {% set name = 'Fabien' %}
    {% set message = 'Hello' %}
    
    {% component Alert with {type: 'success', name: 'Bart'} %}
        Hello {{ name }} {# 'Fabien' from the parent #}
        {{ message }} {{ outerScope.name }} {# 'Hello' and 'Bart' #}
        {{ outerScope.this.someFunction }} {# refers to the parent component #}
    {% endcomponent %}
  8. Fetch services via dependency injection in components

    3.x

    Twig Components are registered as services with shared: false. This allows you to use standard Symfony autowiring to inject repositories or other services directly into the component's constructor. Because they are non-shared, you can safely render the same component multiple times with different data without state collision.

    // src/Twig/Components/FeaturedProducts.php
    namespace App\Twig\Components;
    
    use App\Repository\ProductRepository;
    use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
    
    #[AsTwigComponent]
    class FeaturedProducts
    {
        public function __construct(private ProductRepository $productRepository)
        {
        }
    
        public function getProducts(): array
        {
            return $this->productRepository->findFeatured();
        }
    }

    In Twig, access the service-provided data via this.methodName():

    {# templates/components/FeaturedProducts.html.twig #}
    <div>
        {% for product in this.products %}
            ...
        {% endfor %}
    </div>
  9. Forward blocks using `outerBlocks`

    3.x

    Because component content lives in a separate template that extends the component, standard blocks from the 'outer' template (like those defined in {% block body %} of a page) are not directly accessible.

    To access these blocks, use the outerBlocks variable. This is particularly useful for component forwarding, where a wrapper component captures a block from its caller and passes it down to a nested child component.

    Example Pattern: A SuccessAlert component captures the content block passed to it and forwards it into a generic Alert component using {{ block(outerBlocks.content) }}.

    {# templates/components/SuccessAlert.html.twig #}
    <twig:Alert type="success">
        {{ block(outerBlocks.content) }}
    </twig:Alert>
    
    {# Usage #}
    <twig:SuccessAlert>
        This content is captured and forwarded to the inner Alert.
    </twig:SuccessAlert>
  10. How component naming works

    3.x

    TwigComponent determines a component's name by looking at the namespaces configured in twig_component.yaml. It replaces backslashes (\) with colons (:).

    Component ClassComponent Name
    App\Twig\Components\AlertAlert
    App\Twig\Components\Button\PrimaryButton:Primary

    Customizing the name: You can explicitly set a name using the #[AsTwigComponent] attribute:

    #[AsTwigComponent('alert')]
    class Alert
    {
    }
  11. Nest components inside each other

    3.x

    You can include components inside other components using either HTML syntax (<twig:ComponentName>) or Twig syntax ({% component 'Name' %}).

    Important Caveats:

    1. Variable Merging: Variables of a component are merged with its parent. Variables with the same name (like this or computed) are overridden by the inner component.
    2. Accessing Parent Variables: Use the outerScope variable to access the parent component's variables (e.g., outerScope.this.someFunction()).
    3. Accessing Parent Blocks: Use the outerBlocks variable to access blocks defined in the parent component (e.g., block(outerBlocks.someBlockName)).

    Syntax Restriction: You cannot mix Twig syntax and HTML syntax when nesting. If you use the HTML <twig:...> syntax, you must use the <twig:block> tag for blocks. If you use the Twig {% component %} syntax, you must use standard {% block %} tags.

    {# ✅ This works (HTML syntax only) #}
    <twig:Card>
        <twig:block name="footer">
            <twig:Button:Primary :isBlock="true">Edit</twig:Button:Primary>
        </twig:block>
    </twig:Card>
    
    {# ✅ This works (Twig syntax only) #}
    {% component Card %}
        {% block footer %}
            {% component 'Button:Primary' with {isBlock: true} %}
                {% block content %}Edit{% endblock %}
            {% endcomponent %}
        {% endblock %}
    {% endcomponent %}
  12. Create a Twig Component

    3.x

    A Twig component consists of a PHP class and a Twig template.

    1. The Class: Create a PHP class and decorate it with the #[AsTwigComponent] attribute. The class properties will act as the component's 'props'.
    2. The Template: By default, templates are located in templates/components/{component_name}.html.twig. The component name is derived from the class name based on your configuration.

    You can use the Symfony MakerBundle to scaffold a component quickly:

    $ php bin/console make:twig-component Alert

    To see a list of all registered components, run:

    $ php bin/console debug:twig-component
    // src/Twig/Components/Alert.php
    namespace App	wig\Components;
    
    use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
    
    #[AsTwigComponent]
    class Alert
    {
        public string $type = 'success';
        public string $message;
    }
    {# templates/components/Alert.html.twig #}
    <div class="alert alert-{{ type }}">
        {{ message }}
    </div>