Documentation GitHub logo Fork on Github

Simplify Frontend UI Building

Designed to simplify and speed up the development process of UI by providing a visual and modular development environment in your Symfony application.

Learn more
Screenshot of UI Storia

Direct integration in your Symfony project

Unlike Fractal or StoryBook, which rely on a port of Twig to JS for Symfony projects, UI Storia allows direct work on project templates, thus enabling the safe use of the latest Twig features available.

Integration of UI Storia in your Symfony application

Documentation

Installation

Add iq2i/storia-bundle to your composer.json file:

composer require iq2i/storia-bundle

Register and configure the bundle

If you are using Symfony Flex, the following steps should be done automatically. Otherwise, follow the instructions.

Register the bundle

Inside config/bundles.php, add the following line:

// config/bundles.php

return [
    // ...
    IQ2i\StoriaBundle\IQ2iStoriaBundle::class => ['all' => true],
];

Add routes

Create a new routing file to add UI Storia's routes:

# config/routes/is2i_storia.yaml
iq2i_storia:
    resource: '@IQ2iStoriaBundle/config/routes.php'
    prefix: '/storia'

Create folders where YAML files are stored

Create the storia folder at the root of your project, with two subfolders: components and pages.

Configuration the bundle

By default, UI Storia will try to read YAML files in the storia folder at the root of your application. You can change this behavior by creating a configuration file config/packages/iq2i_storia.yaml with the following content:

# config/packages/iq2i_storia.yaml

iq2i_storia:
    default_path: '%kernel.project_dir%/new-folder'

It is also possible, from this same file, to enable or disable the bundle (specifically to condition its routes) with a configuration parameter:

# config/packages/iq2i_storia.yaml

iq2i_storia:
    # use an environment variable for easier configuration
    enabled: '%env(IQ2I_STORIA_ENABLED)%'

Live Reload

UI Storia comes with a live reload feature that automatically refreshes the preview when you modify your files.

Starting the watcher

Run the following command to start watching for file changes:

bin/console storia:watch

The watcher will display which directories it is monitoring and log every detected change:

Watching [page]: /path/to/project/storia
Watching [iframe]: /path/to/project/templates
Watching [iframe]: /path/to/project/assets
Watching for changes… (Ctrl+C to stop)
[14:23:07] Change detected: /path/to/project/storia/components/badge.yaml (write) → page reload
[14:23:41] Change detected: /path/to/project/templates/ui/badge.html.twig (write) → iframe reload

Press Ctrl+C to stop the watcher. The browser will automatically disconnect from the live reload stream.

Reload behaviour

UI Storia distinguishes between two types of changes:

Configuring watched paths

By default, UI Storia watches templates/ and assets/ for iframe reloads. You can customise this list in your configuration file:

# config/packages/iq2i_storia.yaml

iq2i_storia:
    watch:
        - '%kernel.project_dir%/templates'
        - '%kernel.project_dir%/assets'
        - '%kernel.project_dir%/src/Twig'

The storia/ folder (defined by default_path) is always watched for page reloads and does not need to be listed here.

UI Storia will render your interfaces without CSS or JavaScript.
To instruct UI Storia to use your styles and scripts, simply override the iframe.html.twig template following the Symfony documentation:

{# templates/bundles/IQ2iStoriaBundle/iframe.html.twig #}

{% extends '@!IQ2iStoria/iframe.html.twig' %}

{# if you use WebpackEncoreBundle #}
{% block stylesheets %}
    {{ encore_entry_link_tags('app') }}
{% endblock %}
{% block javascripts %}
   {{ encore_entry_script_tags('app') }}
{% endblock %}

{# if you use AssetMapper #}
{% block javascripts %}
   {% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}

Static export

storia:export generates a static website of your catalog: every variant of every component and page is rendered once and saved as HTML, together with the assets it needs. The result can be deployed on any static host (GitLab Pages, S3, ...) or opened straight from the folder, without PHP and without a web server.

bin/console storia:export [output-dir]

The export stops at the first view, variant or asset that cannot be exported and tells which one:

Variant "components/badge:default" failed: Unable to find template "ui/badge.html.twig"

Output directory

The default directory is var/storia/export. It is emptied at each export, but only if it was created by a previous export (it contains a .storia-export file). Any other non-empty directory is refused.

index.html
components/badge/default.html           # page with menu, tabs and code
iframe/components/badge/default.html    # the rendered variant
bundles/iq2istoria/                     # UI Storia styles and scripts
css/, img/, ...                         # your assets

Assets

The export copies the assets your pages reference (stylesheets, scripts, images, fonts, import maps), and the assets referenced by those stylesheets. A referenced file is taken from public/ when it exists there; otherwise it is requested from your application, so assets served dynamically (AssetMapper in dev for instance) are exported too. Root-relative URLs (/assets/app.css) are rewritten as relative ones, so the export works from a sub-path or from file://.

Assets that are only referenced by JavaScript can be listed in the configuration. Patterns are relative to the public directory and support * and **:

# config/packages/iq2i_storia.yaml

iq2i_storia:
    export:
        output_dir: '%kernel.project_dir%/var/storia/export'
        public_dir: '%kernel.project_dir%/public'
        assets:
            - 'images/**'

Deployment

The export only uses relative links, so it can be served from a sub-path such as https://user.github.io/my-repo/.

The command boots your application: run it in the environment where the bundle is registered (usually dev), with the development dependencies installed. If your assets are built by a tool such as Webpack Encore or Vite, build them before the export.

GitHub Pages

Set Settings > Pages > Source to GitHub Actions, then add this workflow:

# .github/workflows/storia.yaml

name: Storia

on:
    push:
        branches: [main]

permissions:
    contents: read
    pages: write
    id-token: write

concurrency:
    group: pages
    cancel-in-progress: true

jobs:
    deploy:
        runs-on: ubuntu-latest
        environment:
            name: github-pages
            url: ${{ steps.deployment.outputs.page_url }}
        steps:
            - uses: actions/checkout@v4

            - uses: shivammathur/setup-php@v2
              with:
                  php-version: '8.3'

            - run: composer install --no-interaction --prefer-dist

            - run: php bin/console storia:export var/storia/export
              env:
                  APP_ENV: dev

            - uses: actions/upload-pages-artifact@v3
              with:
                  path: var/storia/export

            - id: deployment
              uses: actions/deploy-pages@v4

GitLab Pages

# .gitlab-ci.yml

pages:
    stage: deploy
    image: php:8.3-cli
    variables:
        APP_ENV: dev
    before_script:
        - apt-get update && apt-get install -y git unzip
        - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    script:
        - composer install --no-interaction --prefer-dist
        - php bin/console storia:export var/storia/export
    pages:
        publish: var/storia/export
    rules:
        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

The export is not written to public/, which is the default publish directory of GitLab Pages but also your application's public directory.

Limitations

Testing

Every variant is a test case: the integrator writes the variants, the developer overrides an anonymous component with a PHP class, and storia:test catches the regressions.

bin/console storia:test

The command renders every variant of every component and page, then compares the HTML with its snapshot:

✓ components/badge:default
≠ components/badge:large
✗ components/product:expensive
    Twig\Error\RuntimeError: Variable "price" does not exist.

41 variants, 39 passed, 0 new, 0 updated, 2 failed

The exit code is 1 as soon as one variant fails, 0 otherwise, so the command can be used as is in a CI job. When the CI environment variable is set (GitHub Actions and GitLab CI set it), or with --ci, a missing snapshot is an error instead of being written: a file written on a runner is lost, and the variant would never be compared.

# .gitlab-ci.yml

storia:
    stage: test
    script:
        - composer install --no-interaction --prefer-dist
        - php bin/console storia:test

Snapshots

A snapshot is the HTML of one variant, without the preview page around it, stored in tests/storia/snapshots/<view>/<variant>.html. Commit them: they are reviewed in your merge requests like any other file. Only line endings and trailing spaces are normalized, nothing else is reformatted. The HTML also depends on the rendering stack: upgrading Symfony or Twig can change a form theme or an attribute, review the diff and accept it with --update.

Snapshots are never rewritten silently. When a change is intended, accept it explicitly:

bin/console storia:test --update

A snapshot whose variant no longer exists is reported with ? but does not fail the command; delete the file.

Snapshots can only be stable if the rendering is: !repeat and !lorem are deterministic, but a form with CSRF protection, or a PHP data provider returning dates or random values, is not. Disable the CSRF token in the test environment, make such data deterministic, or use --no-snapshots for it.

# config/packages/iq2i_storia.yaml

iq2i_storia:
    test:
        snapshot_path: '%kernel.project_dir%/tests/storia/snapshots'

Options

Option Description
--filter=<text> Only test the views or variants whose view or view:variant name contains the text
--update Rewrite the snapshots that differ
--no-snapshots Only check that every variant renders
--ci Fail on a missing snapshot instead of writing it (default when the CI environment variable is set)
--snapshot-path=<dir> Use another snapshot directory
-v Show the stack trace of the errors

PHPUnit

For custom assertions, expose the variants as a data provider and use the StoriaTestCaseTrait in a KernelTestCase:

use IQ2i\StoriaBundle\Test\PHPUnit\StoriaTestCaseTrait;
use IQ2i\StoriaBundle\Test\PHPUnit\StoriaVariants;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class StoriaTest extends KernelTestCase
{
    use StoriaTestCaseTrait;

    #[DataProvider('provideVariants')]
    public function testVariant(string $path, string $variant): void
    {
        $view = $this->assertVariantRenders($path, $variant);

        self::assertStringNotContainsString('TODO', (string) $view->getHtmlContent());
    }

    public static function provideVariants(): array
    {
        return StoriaVariants::fromDirectory(__DIR__.'/../storia');
    }
}

StoriaVariants::fromDirectory() reads the YAML files without booting the kernel, as a data provider runs before any test. The StoriaVariants::provide() method can also be used with #[DataProviderExternal]: it reads the directory from the STORIA_PATH environment variable, or ./storia. assertVariantMatchesSnapshot() compares a variant with its snapshot.

Screenshots (Panther) and accessibility checks are not covered yet.

Interfaces

UI Storia allows you to create interfaces for individual components or more complete pages.
You can work with Twig templates from your Symfony application or with local templates that will be used only for UI Storia.

To describe your interfaces, simply create a YAML file in the storia/components or storia/pages folder as follows:

# storia/components/progress.yaml

template: ui/progress.html.twig
variants:
    small:
        args:
            height: 1.5

    default:
        args:
            height: 2.5

    large:
        args:
            height: 4

    extra_large:
        args:
            height: 6

The difference between the storia/components folder and the storia/pages folder is as follows:
By convention, use the storia/components folder for interfaces related to macro components and the storia/pages folder for interfaces for more complete pages.

Here's the configuration details:

Template / Component

The template key is optional because you can also use a Twig template at the same level and with the same name as your YAML file. For example, for your YAML file storia/components/progress.yaml, you can create a file storia/components/progress.html.twig and this file will be used by UI Storia.

UI Storia also provides native support for Twig Component for your interfaces. To do this, simply replace the template key with the component key as shown below:

# storia/components/button.yaml

component: Button
variants:
    plain:
        args:
            class: plain
            label: Plain button

    outline:
        args:
            class: outline
            label: Outline button

Form Types

UI Storia provides native support for Symfony Form Types, allowing you to preview and test form fields in isolation.

To work with a Form Type, use the form key instead of template or component:

# storia/components/form/text.yaml

form: Symfony\Component\Form\Extension\Core\Type\TextType
# storia/components/form/choice.yaml

form: Symfony\Component\Form\Extension\Core\Type\ChoiceType
options:
    choices:
        'In Stock': true
        'Out of Stock': false

Automatic Variants

When using the form key, UI Storia automatically generates three variants for you:

  1. default: The form field in its normal state
  2. disabled: The form field with disabled: true option
  3. error: The form field with a validation error displayed

You cannot define custom variants for form types - these three variants are provided automatically.

Options

The options key allows you to pass options to your Form Type constructor, just like you would when creating a form in Symfony:

# storia/components/form/email.yaml

form: Symfony\Component\Form\Extension\Core\Type\EmailType
options:
    required: false
    attr:
        placeholder: 'Enter your email'

Form Theme

You can also specify a custom form theme for rendering your form field using the form_theme key:

# storia/components/form/custom.yaml

form: App\Form\CustomType
form_theme: 'forms/custom_theme.html.twig'
options:
    label: 'Custom Field'

Variants

The variants key lists the different variations of your interface, passing different arguments to your template.

Each variant is described with a name (which can be overridden as shown in the example below) and an array of arguments called args. Twig Component support also brings a blocks key to configure HTML blocks that will be passed to your component.

# storia/components/button.yaml

component: Button
variants:
    plain:
        args:
            class: plain
        blocks:
            content: Plain button
            svg: '<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="200" height="200" viewBox="0 0 42 42"><path d="M42 20H22V0h-2v20H0v2h20v20h2V22h20z"/></svg>'

    outline:
        name: My outline button 
        args:
            class: outline
        blocks:
            content: Outline button
            svg: '<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="200" height="200" viewBox="0 0 42 42"><path d="M42 20H22V0h-2v20H0v2h20v20h2V22h20z"/></svg>'

Controls

The Controls tab, next to Twig, HTML and Include, lets you change the arguments of the current variant and see the result immediately, without editing the YAML file. Controls are deduced automatically:

Source Where the controls come from
props the {% props %} tag of the component template (names and default values)
class the public properties and the mount() parameters of the component PHP class (types, enums)
types the {% types %} tag of a page template
yaml the args of the variant, typed by their value

A component with {% props title = 'Card title', count = 3, highlighted = false %} gets three controls, even if its YAML file describes no argument. An argument written in the YAML file replaces the default value of the prop with the same name.

The widget is deduced from the value or the PHP type:

Widget When
checkbox a boolean
number an integer or a float
text a string
textarea a string with several lines, or longer than 80 characters
color a string like #1a2b3c
date a property typed \DateTimeInterface, \DateTimeImmutable or \DateTime
radio a backed enum with 4 cases or fewer
select a backed enum with more cases
json an array, edited as JSON

Some controls are read-only: a prop whose default value is a Twig expression, an argument that resolves to a PHP object, or an array too large to be edited by hand. They are displayed with a badge explaining why. blocks and form types have no controls.

Describing the controls

A documentation comment above a prop gives the control a description, and can pick its widget with @control:

{% props
    ## Title of the card
    title = 'Card title',
    ## Number of items @control range min=0 max=10
    count = 3,
    ## Accent color @control color
    accent = '#0f172a',
    ## Alignment of the content @control radio left|center|right
    align = 'left'
%}

The same comment works in the docblock of a public property or of a mount() parameter of a PHP component, and in the {% types %} tag of a page:

/** CSS modifier of the notice @control select info|warning|danger */
public string $level = 'info';

/**
 * @param int $delay Seconds before the snackbar closes @control range min=1 max=30
 */
public function mount(string $message, int $delay = 5): void
{% types {
    ## Main message of the page @control textarea
    message: 'string',
    ## Number of columns @control range min=1 max=4
    columns?: 'number',
} %}

@control accepts text, textarea, color, date, number, range (min, max and step options), select and radio (choices separated by |), checkbox and json. A widget that does not fit the value, such as a range on a string, is ignored and the control says so. For an enum, select and radio only pick the widget: the choices stay the cases of the enum.

Documentation comments need Twig 3.29: before that version, a ## comment inside a tag is a syntax error, so only write them once your project runs Twig 3.29 or later. Reading them from {% props %} also needs symfony/ux-twig-component 3.5; with an older version the controls are still deduced, without description nor @control.

Changes are never written to disk. They are kept in the overrides query parameter of the URL, so reloading the page or sharing the link keeps them, and the Reset button removes them. To keep a state, click Copy variant YAML and paste the result in your YAML file. Values coming from !data or from a PHP data provider are copied once resolved, as literal values.

Overridden values are always passed as plain data, never as Twig code and never through !data or the argument resolver: a string like App\Foo::bar stays a string. However, if your template prints an argument with the |raw filter, a crafted link can inject HTML in the preview: only open Storia links you trust.

The static export has no controls: the tab only displays a note explaining that they are not available.

Data

Variants often need the same data (a menu, a product, a user). Instead of copying it, store it once in a named file under storia/data/ and reference it with the !data tag.

# storia/data/menu.yaml
items:
    - { label: Home, url: / }
    - { label: Shop, url: /shop }
# storia/components/header.yaml
component: Header
variants:
    default:
        args:
            menu: !data menu
# storia/pages/home/home.yaml
variants:
    default:
        args:
            menu: !data menu

Changing storia/data/menu.yaml updates every view that uses it. Files in storia/data/ are not views: they do not appear in the navigation.

Sub-paths

Sub-folders are part of the name (!data shop/catalog), and a dotted path reaches inside the file: !data menu.items.0 or !data shop/product.default.price.

Variants of a piece of data

A file can hold several entries, and with overrides some of their values (mappings are merged, lists are replaced):

# storia/data/product.yaml
default: { name: Chair, price: 49, inStock: true }
expensive: !data { ref: product.default, with: { price: 999 } }
sold_out: !data { ref: product.default, with: { inStock: false } }

Repeating an item: !repeat

!repeat builds a list from a template; {i} is replaced by the position (starting at 1) in every string of the item.

products: !repeat
    count: 4
    item:
        name: 'Product {i}'
        price: 19.99

Filler text: !lorem

!lorem generates a deterministic placeholder text, so the output is stable between two renderings: !lorem 12 (words), !lorem 2 sentences, !lorem 1 paragraph. It works in args and in blocks.

blocks:
    content: !lorem 3

Inside a !repeat, each item gets a different text, so a list of cards does not repeat the same sentence:

articles: !repeat
    count: 3
    item:
        title: !lorem 4
        summary: !lorem 2 sentences

PHP data providers

When the data needs logic (relative dates, computed values, business objects), use the same tag with a PHP method: !data App\Factory\ProductFactory::createDefault.

Tags are resolved in the args and blocks of a variant, and in the options of a form view.

Argument Resolver

UI Storia provides an Argument Resolver feature that allows you to dynamically generate arguments by calling PHP methods directly from your YAML configuration files.

Instead of hardcoding values in your variant arguments, you can reference a PHP class method with !data ClassName::methodName. UI Storia will call the method to generate the argument value.

How it works

The Argument Resolver supports both:

Example
# storia/components/product.yaml

component: Product
variants:
    default:
        args:
            product: !data App\Factory\ProductFactory::createDefault
        blocks:
            content: ''

    expensive:
        args:
            product: !data App\Factory\ProductFactory::createExpensive
        blocks:
            content: ''

    outOfStock:
        args:
            product: !data App\Factory\ProductFactory::createOutOfStock
        blocks:
            content: ''

    static:
        args:
            product:
                id: 99
                name: Static Product
                price: 19.99
                inStock: true
        blocks:
            content: ''

In this example, the first three variants use the Argument Resolver to call methods from ProductFactory, while the static variant uses hardcoded values.

The ProductFactory class could look like this:

<?php

namespace App\Factory;

class ProductFactory
{
    public static function createDefault(): array
    {
        return [
            'id' => 1,
            'name' => 'Default Product',
            'price' => 29.99,
            'inStock' => true,
        ];
    }

    public static function createExpensive(): array
    {
        return [
            'id' => 2,
            'name' => 'Premium Product',
            'price' => 199.99,
            'inStock' => true,
        ];
    }

    public static function createOutOfStock(): array
    {
        return [
            'id' => 3,
            'name' => 'Sold Out Product',
            'price' => 49.99,
            'inStock' => false,
        ];
    }
}

Advanced: include and extends

To share data between views, prefer named data. include: and extends: are advanced features meant for sharing YAML structure.

UI Storia supports two directives to avoid duplication across your YAML files: include: for sharing anchor definitions, and extends: for inheriting and overriding a complete configuration.

include: — Shared anchor definitions

The include: directive lets you define YAML anchors in a shared file and reuse them across multiple configuration files.

Create a shared file anywhere in your storia/ folder (conventionally prefixed with _ to indicate it is not a standalone view):

# storia/_shared.yaml

_definitions:
    default_badge: &default_badge
        class: bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded

    large_badge: &large_badge
        class: bg-blue-100 text-blue-800 text-sm font-medium px-2.5 py-0.5 rounded

Then include it from any configuration file using include: and reference the anchors with the standard YAML * syntax:

# storia/components/badge.yaml

include: _shared

template: ui/badge.html.twig
variants:
    default:
        args:
            <<: *default_badge

    large:
        args:
            <<: *large_badge

You can include multiple files using a list:

include: [_colors, _sizes]

# or equivalently:
include:
    - _colors
    - _sizes

extends: — Configuration inheritance

The extends: directive allows a configuration file to inherit all settings from a parent file and selectively override only the parts that differ.

# storia/components/badge.yaml

template: ui/badge.html.twig
variants:
    default:
        args:
            class: bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded
    large:
        args:
            class: bg-blue-100 text-blue-800 text-sm font-medium px-2.5 py-0.5 rounded
    bordered:
        args:
            class: bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded border border-blue-400
# storia/components/badge-red.yaml

extends: components/badge

variants:
    default:
        args:
            class: bg-red-100 text-red-800 text-xs font-medium px-2.5 py-0.5 rounded
    large:
        args:
            class: bg-red-100 text-red-800 text-sm font-medium px-2.5 py-0.5 rounded

In this example, badge-red.yaml inherits the template key and the bordered variant from the parent, and only overrides default and large.

The merge is deep: nested keys (like args within a variant) are merged individually, so you can override a single argument without redefining all the others.

Extensions can be chained — a file that uses extends: can itself be extended by another file.