Designed to simplify and speed up the development process of UI by providing a visual and modular development environment in your Symfony application.
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.
Add iq2i/storia-bundle to your composer.json file:
composer require iq2i/storia-bundle
If you are using Symfony Flex, the following steps should be done automatically. Otherwise, follow the instructions.
Inside config/bundles.php, add the following line:
// config/bundles.php
return [
// ...
IQ2i\StoriaBundle\IQ2iStoriaBundle::class => ['all' => true],
];
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 the storia folder at the root of your project, with two subfolders: components and pages.
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)%'
UI Storia comes with a live reload feature that automatically refreshes the preview when you modify your files.
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.
UI Storia distinguishes between two types of changes:
storia/ folder (YAML files) trigger a full page reload — the menu and component metadata are refreshed.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 %}
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"
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
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/**'
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.
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-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.
http://localhost:5173), are not rewritten.asset() configured with base_urls) are neither rewritten nor copied: the exported pages keep pointing to that host.file:// pages: a project that loads its own modules must serve the folder over HTTP, or be opened with Firefox.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 variant cannot be rendered (Twig or PHP error). It always fails the command.≠ the rendering differs from the snapshot. A diff is shown (install sebastian/diff to get it) and the command fails.+ the variant has no snapshot yet: it is written and the command does not fail, except in CI (see below).↻ the snapshot was rewritten because of --update.? a view defines no variant, or a snapshot has no variant any more: nothing is tested. A view without variant fails the command in CI.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
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'
| 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 |
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.
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: Corresponds to the path to the Twig template of your Symfony application that you want to work with.variants: This is the list of different variants you want to create.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
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
When using the form key, UI Storia automatically generates three variants for you:
disabled: true optionYou cannot define custom variants for form types - these three variants are provided automatically.
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'
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'
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>'
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.
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.
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-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.
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 } }
!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
!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
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.
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.
The Argument Resolver supports both:
# 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,
];
}
}
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 definitionsThe 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 inheritanceThe 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.