DEMO / LiveComponent

Invoice Creator

Create or edit an Invoice entity along with child components for each related InvoiceItem entity.
Children components emit events to communicate to the parent and everything is saved in a saveInvoice LiveAction method.

Invoice Items
Product Price Quantity  
Subtotal: $0.00
Tax rate:
%
Total: $0.00

This main component keeps track of the Invoice (which may be new) and a list of the "invoice items" - stored on a LiveProp called $lineItems.

Because LiveProp values need to be (mostly) simple, the $lineItems are stored as a raw array of data, which we add to or remove from when line items are added/removed.

This components listens to several events that the child InvoiceCreatorLineItem components emits. For example, the child emits removeLineItem when the user clicks the "x" button on a line item. This triggers the removeLineItem() method on this component, which removes the line item from the $lineItems array.

// ... use statements hidden - click to show
use App\Entity\Invoice;
use App\Entity\InvoiceItem;
use App\Entity\Product;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Validator\Constraints\Valid;
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 InvoiceCreator extends AbstractController
{
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp(writable: ['customerName', 'customerEmail', 'taxRate'])]
    #[Valid]
    public Invoice $invoice;

    #[LiveProp]
    public array $lineItems = [];

    /**
     * A temporary flag that we just saved.
     *
     * This doesn't need to be a LiveProp because it's set in a LiveAction,
     * rendered immediately, then we want it to be forgotten.
     */
    public bool $savedSuccessfully = false;
    public bool $saveFailed = false;

    public function __construct(private ProductRepository $productRepository)
    {
    }

    // add mount method
    public function mount(Invoice $invoice): void
    {
        $this->invoice = $invoice;
        $this->lineItems = $this->populateLineItems($invoice);
    }

    #[LiveAction]
    public function addLineItem(): void
    {
        $this->lineItems[] = [
            'productId' => null,
            'quantity' => 1,
            'isEditing' => true,
        ];
    }

    #[LiveListener('removeLineItem')]
    public function removeLineItem(#[LiveArg] int $key): void
    {
        unset($this->lineItems[$key]);
    }

    #[LiveListener('line_item:change_edit_mode')]
    public function onLineItemEditModeChange(#[LiveArg] int $key, #[LiveArg] $isEditing): void
    {
        $this->lineItems[$key]['isEditing'] = $isEditing;
    }

    #[LiveListener('line_item:save')]
    public function saveLineItem(#[LiveArg] int $key, #[LiveArg] Product $product, #[LiveArg] int $quantity): void
    {
        if (!isset($this->lineItems[$key])) {
            // shouldn't happen
            return;
        }

        $this->lineItems[$key]['productId'] = $product->getId();
        $this->lineItems[$key]['quantity'] = $quantity;
    }

    #[LiveAction]
    public function saveInvoice(EntityManagerInterface $entityManager)
    {
        $this->saveFailed = true;
        $this->validate();
        $this->saveFailed = false;

        // TODO: do we check for `isSaved` here... and throw an error?

        // remove any items that no longer exist
        foreach ($this->invoice->getInvoiceItems() as $key => $item) {
            if (!isset($this->lineItems[$key])) {
                // orphanRemoval will cause these to be deleted
                $this->invoice->removeInvoiceItem($item);
            }
        }

        foreach ($this->lineItems as $key => $lineItem) {
            $invoiceItem = $this->invoice->getInvoiceItems()->get($key);
            if (null === $invoiceItem) {
                // this is a new item! Welcome!
                $invoiceItem = new InvoiceItem();
                $entityManager->persist($invoiceItem);
                $this->invoice->addInvoiceItem($invoiceItem);
            }

            $product = $this->findProduct($lineItem['productId']);
            $invoiceItem->setProduct($product);
            $invoiceItem->setQuantity($lineItem['quantity']);
        }

        $isNew = null === $this->invoice->getId();
        $entityManager->persist($this->invoice);
        $entityManager->flush();

        if ($isNew) {
            // it's new! Let's redirect to the edit page
            $this->addFlash('live_demo_success', 'Invoice saved!');

            return $this->redirectToRoute('app_demo_live_component_invoice', [
                'id' => $this->invoice->getId(),
            ]);
        }

        // it's not new! We should already be on the edit page, so let's
        // just let the component stay rendered.
        $this->savedSuccessfully = true;

        // Keep the lineItems in sync with the invoice: new InvoiceItems may
        //      not have been given the same key as the original lineItems
        $this->lineItems = $this->populateLineItems($this->invoice);
    }

    public function getSubtotal(): float
    {
        $subTotal = 0;

        foreach ($this->lineItems as $lineItem) {
            if (!$lineItem['productId']) {
                continue;
            }

            $product = $this->findProduct($lineItem['productId']);

            $subTotal += ($product->getPrice() * $lineItem['quantity']);
        }

        return $subTotal / 100;
    }

    public function getTotal(): float
    {
        $taxMultiplier = 1 + ($this->invoice->getTaxRate() / 100);

        return $this->getSubtotal() * $taxMultiplier;
    }

    #[ExposeInTemplate]
    public function areAnyLineItemsEditing(): bool
    {
        foreach ($this->lineItems as $lineItem) {
            if ($lineItem['isEditing']) {
                return true;
            }
        }

        return false;
    }

    private function populateLineItems(Invoice $invoice): array
    {
        $lineItems = [];
        foreach ($invoice->getInvoiceItems() as $item) {
            $lineItems[] = [
                'productId' => $item->getProduct()->getId(),
                'quantity' => $item->getQuantity(),
                'isEditing' => false,
            ];
        }

        return $lineItems;
    }

    private function findProduct(int $id): Product
    {
        return $this->productRepository->find($id);
    }
}

The template is fairly simple: rendering form fields with data-model to bind to writable LiveProp's along with their validation errors.

Most importantly, this loops over $lineItems and renders the InvoiceCreatorLineItem child component for each one passing the data: productId, quantity, and isEditing. It also passes a key, which is needed so LiveComponents can track which row is which.

<div {{ attributes }}>
    <form data-action="live#action:prevent" data-live-action-param="saveInvoice">
        <div class="mb-3">
            <label for="customer-name">Customer name:</label>
            <input
                type="text"
                data-model="invoice.customerName"
                class="form-control {{ _errors.has('invoice.customerName') ? 'is-invalid' }}"
                id="customer-name"
            >
            {% if _errors.has('invoice.customerName') %}
                <div class="invalid-feedback">
                    {{ _errors.get('invoice.customerName') }}
                </div>
            {% endif %}
        </div>

        <div class="mb-3">
            <label for="customer-email">Billing Email:</label>
            <input
                type="email"
                data-model="invoice.customerEmail"
                class="form-control {{ _errors.has('invoice.customerEmail') ? 'is-invalid' }}"
                id="customer-email"
            >
            {% if _errors.has('invoice.customerEmail') %}
                <div class="invalid-feedback">
                    {{ _errors.get('invoice.customerEmail') }}
                </div>
            {% endif %}
        </div>

        <div class="card">
            <div class="card-header">Invoice Items</div>
            <div class="card-body">
                <table class="table">
                    <thead>
                        <tr>
                            <th>Product</th>
                            <th style="width: 100px;">Price</th>
                            <th style="width: 100px;">Quantity</th>
                            <th style="width: 100px;">&nbsp;</th>
                        </tr>
                    </thead>
                    <tbody>
                        {% for key, line in lineItems %}
                            <twig:InvoiceCreatorLineItem
                                key="{{ key }}"
                                productId="{{ line.productId }}"
                                quantity="{{ line.quantity }}"
                                isEditing="{{ line.isEditing }}"
                            />
                        {% endfor %}
                    </tbody>
                </table>

                <button
                    data-action="live#action"
                    data-live-action-param="addLineItem"
                    class="btn btn-sm btn-secondary"
                    type="button"
                ><twig:Icon name="plus" /> Add Item</button>
            </div>
        </div>

        <div class="col-4 offset-8 mt-4">
            <table class="table text-end">
                <tbody>
                    <tr>
                        <th>Subtotal:</th>
                        <td>{{ this.subtotal|format_currency('USD') }}</td>
                    </tr>
                    <tr>
                        <th>Tax rate:</th>
                        <td class="d-flex justify-content-end">
                            <div style="width: 110px;" class="input-group {{ _errors.has('invoice.taxRate') ? 'is-invalid' }}">
                                <input
                                    type="number"
                                    data-model="invoice.taxRate"
                                    class="form-control"
                                >
                                <span class="input-group-text">%</span>
                            </div>
                            {% if _errors.has('invoice.taxRate') %}
                                <div class="invalid-feedback">
                                    {{ _errors.get('invoice.taxRate') }}
                                </div>
                            {% endif %}
                        </td>
                    </tr>
                    <tr>
                        <th>Total:</th>
                        <td>{{ this.total|format_currency('USD') }}</td>
                    </tr>
                </tbody>
            </table>
        </div>

        <button
            class="btn btn-primary"
            {{ areAnyLineItemsEditing ? 'disabled' : '' }}
        >
            <span data-loading="action(saveInvoice)|show">
                <twig:Icon name="spinner" style="animation: spin 1s linear infinite;" />
            </span>
            {% if savedSuccessfully %}
                <twig:Icon name="circle-check" class="text-success" />
            {% endif %}
            {% if saveFailed %}
                <twig:Icon name="circle-exclamation" />
            {% endif %}
            Save Invoice
        </button>
        {% if saveFailed %}
            <small class="text-secondary">Check above for errors</small>
        {% endif %}
        {% if areAnyLineItemsEditing %}
            <small class="text-secondary">Save all line items before continuing.</small>
        {% endif %}
    </form>
</div>

The child component for each "line item". This handles validating, saving, and changing the "edit" state of the line item.

But all of the line item data ultimately needs to be stored on the parent component so that we can wait to save everything to the database. This component communicates the new data (or "edit" state change) to the parent by emitting events - e.g. line_item:save.

Note: it would be simpler to manage the isEditing state directly on this component, instead of passing it to the parent. It was done this way so that the parent component can know how many of its children are currently in "edit" mode.

// ... use statements hidden - click to show
use App\Entity\Product;
use App\Repository\ProductRepository;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\LiveResponder;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

#[AsLiveComponent]
class InvoiceCreatorLineItem
{
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp]
    public int $key;

    #[LiveProp(writable: true)]
    #[Assert\NotNull]
    public ?Product $product = null;

    #[LiveProp(writable: true)]
    #[Assert\Positive]
    public int $quantity = 1;

    #[LiveProp]
    public bool $isEditing = false;

    public function __construct(private ProductRepository $productRepository)
    {
    }

    public function mount(?int $productId): void
    {
        if ($productId) {
            $this->product = $this->productRepository->find($productId);
        }
    }

    #[LiveAction]
    public function save(LiveResponder $responder): void
    {
        $this->validate();

        $responder->emitUp('line_item:save', [
            'key' => $this->key,
            'product' => $this->product->getId(),
            'quantity' => $this->quantity,
        ]);

        $this->changeEditMode(false, $responder);
    }

    #[LiveAction]
    public function edit(LiveResponder $responder): void
    {
        $this->changeEditMode(true, $responder);
    }

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

    private function changeEditMode(bool $isEditing, LiveResponder $responder): void
    {
        $this->isEditing = $isEditing;

        // emit to InvoiceCreator so it can track which items are being edited
        $responder->emitUp('line_item:change_edit_mode', [
            'key' => $this->key,
            'isEditing' => $this->isEditing,
        ]);
    }
}

Nothing too fancy here: some data-model elements and data-action="live#action" buttons.

The most interesting part is the "X" button to remove a line item: this uses data-action="live#emitUp" to emit the removeLineItem event to the parent component. In this case, instead of triggering a LiveAction that then emits the event, we emit the event directly.

<tr {{ attributes }}>
    <td>
        {% if isEditing %}
            <select
                data-model="product"
                aria-label="Choose a Product"
                class="form-control {{ _errors.has('product') ? 'is-invalid' }}"
            >
                <option value="" {{ not product ? 'selected' }}>Choose a Product</option>
                {% for productOption in products %}
                    <option
                        value="{{ productOption.id }}"
                        {% if productOption == product %}selected{% endif %}
                    >
                        {{ productOption.name }} ({{ productOption.priceInCents|format_currency('USD') }})
                    </option>
                {% endfor %}
            </select>
            {% if _errors.has('product') %}
                <div class="invalid-feedback">
                    {{ _errors.get('product') }}
                </div>
            {% endif %}

        {% else %}
            {{ product.name }}
        {% endif %}
    </td>

    <td>
        {% if not isEditing %}
            {{ product.priceInCents|format_currency('USD') }}
        {% endif %}
    </td>

    <td>
        {% if isEditing %}
            <input
                type="number"
                data-model="quantity"
                aria-label="Quantity"
                class="form-control {{ _errors.has('quantity') ? 'is-invalid' }}"
            >
            {% if _errors.has('quantity') %}
                <div class="invalid-feedback">
                    {{ _errors.get('quantity') }}
                </div>
            {% endif %}
        {% else %}
            {{ quantity }}
        {% endif %}
    </td>
    <td class="text-end text-nowrap">
        {% if isEditing %}
            <button
                data-action="live#action"
                data-live-action-param="save"
                class="btn btn-success btn-sm"
                type="button"
            >Save</button>
        {% else %}
            <button
                data-action="live#action"
                data-live-action-param="edit"
                class="btn btn-primary btn-sm"
                type="button"
            >Edit</button>
        {% endif %}

        <button
            data-action="live#emitUp"
            data-live-event-param="removeLineItem"
            data-live-key-param="{{ key }}"
            class="btn btn-link text-danger btn-sm ml-2"
            type="button"
        ><twig:Icon name="cross" /></button>
    </td>
</tr>