DEMO / LiveComponent

Product Form + Category Modal

Open a child modal component to create a new Category.

Live component with a form, ValidatableComponentTrait and a saveProduct() LiveAction for instant validation & an AJAX submit.

The real magic comes from #[LiveListener('category:created'). This is emitted by the NewCategoryForm component (which opens in a modal) when a new category is created.

Note: the category:created event emits category as an integer. Then, thanks to the Category type-hint + Symfony's standard controller argument behavior, Symfony uses that id to query for the Category object.

// ... use statements hidden - click to show
use App\Entity\Category;
use App\Entity\Product;
use App\Repository\CategoryRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

#[AsLiveComponent]
class NewProductForm extends AbstractController
{
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    public function __construct(private CategoryRepository $categoryRepository)
    {
    }

    #[LiveProp(writable: true)]
    #[NotBlank]
    public string $name = '';

    #[LiveProp(writable: true)]
    public int $price = 0;

    #[LiveProp(writable: true)]
    #[NotBlank]
    public ?Category $category = null;

    #[ExposeInTemplate]
    public function getCategories(): array
    {
        return $this->categoryRepository->findAll();
    }

    #[LiveListener('category:created')]
    public function onCategoryCreated(#[LiveArg] Category $category): void
    {
        // change category to the new one
        $this->category = $category;

        // the re-render will also cause the <select> to re-render with
        // the new option included
    }

    public function isCurrentCategory(Category $category): bool
    {
        return $this->category && $this->category === $category;
    }

    #[LiveAction]
    public function saveProduct(EntityManagerInterface $entityManager): Response
    {
        $this->validate();
        $product = new Product();
        $product->setName($this->name);
        $product->setPrice($this->price);
        $product->setCategory($this->category);
        $entityManager->persist($product);
        $entityManager->flush();

        $this->addFlash('live_demo_success', 'Product created! Add another one!');

        return $this->redirectToRoute('app_demo_live_component_product_form');
    }
}

Near the bottom, this renders the BootstrapModal component with another component - NewCategoryForm - inside of it. Opening the modal is done entirely with normal Bootstrap logic: an a tag with data-bs-toggle="modal" and data-bs-target="#new-category-modal".

<div {{ attributes }}>
    <form
        data-action="live#action:prevent"
        data-live-action-param="saveProduct"
    >
        <div class="row align-items-center">
            <div class="col-2">
                <label for="product-name">Product name:</label>
            </div>
            <div class="col-3">
                <input
                    type="text"
                    data-model="name"
                    class="form-control {{ _errors.has('name') ? 'is-invalid' }}"
                    id="product-name"
                >
                {% if _errors.has('name') %}
                    <div class="invalid-feedback">
                        {{ _errors.get('name') }}
                    </div>
                {% endif %}
            </div>
        </div>

        <div class="row align-items-center mt-3">
            <div class="col-2">
                <label for="product-price">Price:</label>
            </div>
            <div class="col-3">
                <input
                    type="text"
                    data-model="price"
                    class="form-control {{ _errors.has('price') ? 'is-invalid' }}"
                    id="product-price"
                >
                {% if _errors.has('price') %}
                    <div class="invalid-feedback">
                        {{ _errors.get('price') }}
                    </div>
                {% endif %}
            </div>
        </div>

        <div class="row align-items-center mt-3">
            <div class="col-2">
                <label for="product-category">Category:</label>
            </div>
            <div class="col-3">
                <select
                    data-model="category"
                    id="product-category"
                    class="form-control {{ _errors.has('category') ? 'is-invalid' }}"
                >
                    <option value="">Choose a category</option>
                    {% for categoryOption in categories %}
                        <option value="{{ categoryOption.id }}" {{ this.isCurrentCategory(categoryOption) ? 'selected' }}>{{ categoryOption.name }}</option>
                    {% endfor %}
                </select>
                {% if _errors.has('category') %}
                    <div class="invalid-feedback">
                        {{ _errors.get('category') }}
                    </div>
                {% endif %}
            </div>
            <div class="col-auto">
                <div class="form-text">
                    <a
                        type="button"
                        data-bs-toggle="modal"
                        data-bs-target="#new-category-modal"
                    >+ Add Category
                    </a>
                </div>
            </div>
        </div>

        <div class="mt-3">
            <button type="submit" class="btn btn-primary">Save Product</button>
        </div>
    </form>

    {% component BootstrapModal with {id: 'new-category-modal'} %}
        {% block modal_header %}
            <h5>Add a Category</h5>
            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        {% endblock %}
        {% block modal_body %}
            <twig:NewCategoryForm />
        {% endblock %}
    {% endcomponent %}
</div>

This component opens up in the modal! It has a #[LiveAction] that saves the new Category to the database and then does two important things:

  1. Emits the category:created event with the new Category's id (see NewProductForm.php).
  2. Dispatches a browser event called modal:closed to close the modal (see bootstrap-modal-controller.js).
// ... use statements hidden - click to show
use App\Entity\Category;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\LiveResponder;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;

#[AsLiveComponent]
class NewCategoryForm
{
    use ComponentToolsTrait;
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp(writable: true)]
    #[NotBlank]
    public string $name = '';

    #[LiveAction]
    public function saveCategory(EntityManagerInterface $entityManager, LiveResponder $liveResponder): void
    {
        $this->validate();

        $category = new Category();
        $category->setName($this->name);
        $entityManager->persist($category);
        $entityManager->flush();

        $this->dispatchBrowserEvent('modal:close');
        $this->emit('category:created', [
            'category' => $category->getId(),
        ]);

        // reset the fields in case the modal is opened again
        $this->name = '';
        $this->resetValidation();
    }
}
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
class BootstrapModal
{
    public ?string $id = null;
}
<div {{ attributes.defaults({
    class: 'modal fade',
    tabindex: '-1',
    'aria-hidden': 'true',
    id: id ? id : false,
}) }}
    data-controller="bootstrap-modal"
>
    <div class="modal-dialog">
        <div class="modal-content">
            {% block modal_full_content %}
                {% if block('modal_header') %}
                    <div class="modal-header">
                        {% block modal_header %}{% endblock %}
                    </div>
                {% endif %}

                <div class="modal-body">
                    {% block modal_body %}{% endblock %}
                </div>

                {% if block('modal_footer') %}
                    <div class="modal-footer">
                        {% block modal_footer %}{% endblock %}
                    </div>
                {% endif %}
            {% endblock %}
        </div>
    </div>
</div>
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';

/**
 * Allows you to dispatch a "modal:close" JavaScript event to close it.
 *
 * This is useful inside a LiveComponent, where you can emit a browser event
 * to open or close the modal.
 *
 * See templates/components/BootstrapModal.html.twig to see how this is
 * attached to Bootstrap modal.
 */
/* stimulusFetch: 'lazy' */
export default class extends Controller {
    modal = null;

    connect() {
        this.modal = Modal.getOrCreateInstance(this.element);
        document.addEventListener('modal:close', () => this.modal.hide());
    }
}