DEV Community

Paul Clegg
Paul Clegg

Posted on • Originally published at clegginabox.co.uk on

Orchestrated UI with Symfony UX and Mercure

Orchestrated UI with Symfony UX and Mercure

In a previous post I wrote about a workflow/server driven UI using Symfony Forms and React. I created a demo to go with it, but it was rough and incomplete. Plus having a back-end sending a JSON representation of a form was only ever going to get me so far.

The demo used a Temporal Workflow which mapped "steps" to Symfony forms. An API endpoint returned a JSON Schema representation of the Symfony form to a React app for rendering. Once the form was submitted, the workflow would move onto the next "step" and return a different form. This process repeated until the workflow completed.

The use case I had in mind for this process was vehicle breakdown handling, it's a very complex procedure - there's many forks in the path, steps with external dependencies, many different members of staff could be involved and it could potentially span weeks of time. I didn't want to use React for this; it meant one more codebase, one more deployment, and one more thing to go wrong.

I've been aware of Symfony UX for quite some time, I'd just never had the time nor opportunity to use it. I did wonder if it was possible to implement the same pattern with it. That way - all the code is PHP and it's all in the same codebase.

As for "the pattern" - I'm calling it Orchestrated UI. It isn't Server-Driven UI as the workflow manages the state, and the interface follows. I’m not sure if this is a standard pattern yet - if you know of similar examples or a better name, please let me know!


Recently, I finally found time to dive into Symfony UX. I’ve almost finished a project which I hope to deploy as a live demo soon. It uses this same pattern to integrate multiple LLMs, human-in-the-loop steps, a live scoreboard and a few other bits.

In the meantime I wanted to share some simpler examples and the (sort of) library I've created to build it.

It works a bit like this:

  • A controller starts a workflow and redirects to a workflow‑specific URL.
  • A Live Component queries the workflow state and renders the correct step UI.
  • Form submissions signal the workflow with step data.
  • The workflow executes step handlers (including timed steps and activities), updates state, and publishes updates.
  • Mercure pushes those updates to the browser so the UI re-renders without polling.

Below is a video of the 'Takeaway' example.

Almost no Javascript was harmed in the making of this demo

The server controls the view. A hard refresh brings you right back to the correct step. Zero complex state management required.

I’ve considered many use cases for this pattern, but one stands out - it was just too large to build for this specific demo: The Bulk CSV Import.

I'm sure we've all had to build one. A client wants the ability to batch create users or products. It sounds simple enough but it's typically a headache.

Do you validate the entire file upfront, rejecting 10,000 valid rows just because Column 6 on Row 231 is invalid?

Do you process it in the background and send an email report later with all the rows that failed?

If you offload it to a queue - how do you pipe the progress back to the browser?

With an Orchestrated UI, the workflow can process the file row-by-row (or fan out and process multiple concurrently) and stream the progress back to the user. Crucially, if it hits an invalid row, it can tell the UI to render a pre-populated form for that specific item. The user fixes it and the workflow continues, all while the rest of the file processes in the background.

You've seen the end result in the video above. So onto some code.


Orchestrated UI with Symfony UX and Mercure

The Blueprint

For the Takeaway demo, the steps range from interactive user actions (ordering) to passive states (waiting for food).

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway;

enum Takeaway: string
{
    case MENU = 'menu';
    case ADDRESS = 'address';
    case MAP = 'map';
    case PREPARE_FOOD = 'prepare_food';
    case DELIVER_FOOD = 'deliver_food';
    case DELIVERED = 'delivered';
}
Enter fullscreen mode Exit fullscreen mode

Here is the flow we are modeling:

  • MENU: The user selects what they want to order.
  • ADDRESS: The user enters their name and address.
  • MAP: The system geocodes the address; the user confirms the pin location on a map.
  • PREPARE_FOOD: A passive state where the user waits while food is prepared.
  • DELIVER_FOOD: A passive state while the food is out for delivery.
  • DELIVERED: The process completes.

Interactive steps need a structure to capture user input. We use standard DTOs to hold the data and Symfony Forms to validate it.

For example, the Address step requires a DTO:

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway\Dto;

class AddressDto
{
    public ?string $name = null;

    public ?string $street = null;

    public ?string $city = null;

    public ?string $postcode = null;

    public ?string $instructions = null;
}

Enter fullscreen mode Exit fullscreen mode

And a corresponding FormType to render the inputs:

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway\Form;

use App\Demo\Takeaway\Dto\AddressDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * @extends AbstractType<AddressDto>
 */
class AddressType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class, [
                'label' => 'Name',
                'attr' => ['placeholder' => 'Alex Rider'],
            ])
            ->add('street', TextType::class, [
                'label' => 'Street address',
                'attr' => ['placeholder' => '22 Market Street'],
            ])
            ->add('city', TextType::class, [
                'label' => 'City',
                'attr' => ['placeholder' => 'London'],
            ])
            ->add('postcode', TextType::class, [
                'label' => 'Postcode',
                'attr' => ['placeholder' => 'E1 6AN'],
            ])
            ->add('instructions', TextareaType::class, [
                'label' => 'Delivery instructions',
                'required' => false,
                'attr' => [
                    'rows' => 2,
                    'placeholder' => 'Door code, drop-off spot, etc.',
                ],
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'label' => 'Delivery address',
            'help' => 'We will ask you to confirm the location on the map next.',
            'data_class' => AddressDto::class,
        ]);
    }
}

Enter fullscreen mode Exit fullscreen mode

With the input structures defined, we build the Workflow State. This is the single source of truth, persisted by Temporal and pushed to the UI via Mercure.

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway;

use App\Demo\Takeaway\Dto\AddressDto;
use App\Demo\Takeaway\Dto\MenuSelectionDto;
use App\StepKit\FlowState;

class TakeawayState extends FlowState
{
    public ?string $orderNumber = null;

    public ?MenuSelectionDto $menu = null;

    public ?AddressDto $address = null;

    public bool $mapConfirmed = false;

    public ?float $latitude = null;

    public ?float $longitude = null;
}

Enter fullscreen mode Exit fullscreen mode

Next, we define the logic. Each step maps to a Handler that executes the business rules.

This is the heart of the 'Orchestrated UI' pattern. Notice the Workflow::await call below. The workflow pauses execution here - blocking until the condition is met.

This is why you don't need complex frontend state management. If the user hard-refreshes or visits from a different device, the UI just asks the server 'Where were we?' and the workflow replies, 'I'm still waiting for the address.'"

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway\Handler;

use App\Demo\Takeaway\Takeaway;
use App\Demo\Takeaway\TakeawayState;
use App\Demo\Takeaway\Workflow\Activities\TakeawayActivitiesInterface;
use App\StepKit\FlowState;
use App\StepKit\StepHandlerInterface;
use App\StepKit\StepResult;
use Generator;
use Temporal\Workflow;

/**
 * @implements StepHandlerInterface<TakeawayState>
 */
class AddressHandler implements StepHandlerInterface
{
    /**
     * @param TakeawayState $state
     */
    public function execute(FlowState $state, object $activity, ?object $notifier = null): Generator
    {
        yield Workflow::await(
            static fn(): bool => $state->address !== null
        );

        /** @var TakeawayActivitiesInterface $activity */
        $location = yield $activity->geocodeAddress($state->address);

        if ($location?->getCoordinates() !== null) {
            $state->latitude = $location->getCoordinates()->getLatitude();
            $state->longitude = $location->getCoordinates()->getLongitude();
        }

        return StepResult::next(Takeaway::MAP);
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we wire everything together in the Step Catalog.

This registry tells the engine exactly which Live Component to render for a given step, and which Form/DTO to use if input is required. It connects the backend logic directly to the frontend view.

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway;

use App\Demo\Takeaway\Dto\AddressDto;
use App\Demo\Takeaway\Dto\MapConfirmDto;
use App\Demo\Takeaway\Dto\MenuSelectionDto;
use App\Demo\Takeaway\Form\AddressType;
use App\Demo\Takeaway\Form\MapConfirmType;
use App\Demo\Takeaway\Form\MenuType;
use App\Demo\Takeaway\Handler\AddressHandler;
use App\Demo\Takeaway\Handler\DeliverFoodHandler;
use App\Demo\Takeaway\Handler\DeliveredHandler;
use App\Demo\Takeaway\Handler\MapConfirmHandler;
use App\Demo\Takeaway\Handler\MenuHandler;
use App\Demo\Takeaway\Handler\PrepareFoodHandler;
use App\StepKit\StepDefinition;

final readonly class TakeawaySteps
{
    /**
     * @return StepDefinition[]
     */
    public static function all(): array
    {
        return [
            new StepDefinition(
                id: Takeaway::MENU,
                handlerClass: MenuHandler::class,
                liveComponent: 'TakeawayForm',
                formType: MenuType::class,
                dtoClass: MenuSelectionDto::class,
            ),
            new StepDefinition(
                id: Takeaway::ADDRESS,
                handlerClass: AddressHandler::class,
                liveComponent: 'TakeawayForm',
                formType: AddressType::class,
                dtoClass: AddressDto::class,
            ),
            new StepDefinition(
                id: Takeaway::MAP,
                handlerClass: MapConfirmHandler::class,
                liveComponent: 'TakeawayMapConfirm',
                formType: MapConfirmType::class,
                dtoClass: MapConfirmDto::class,
            ),
            new StepDefinition(
                id: Takeaway::PREPARE_FOOD,
                handlerClass: PrepareFoodHandler::class,
                liveComponent: 'TakeawayPreparing',
            ),
            new StepDefinition(
                id: Takeaway::DELIVER_FOOD,
                handlerClass: DeliverFoodHandler::class,
                liveComponent: 'TakeawayOutForDelivery',
            ),
            new StepDefinition(
                id: Takeaway::DELIVERED,
                handlerClass: DeliveredHandler::class,
                liveComponent: 'TakeawayDelivered',
                terminal: true
            ),
        ];
    }
}

Enter fullscreen mode Exit fullscreen mode

The Orchestrator

Now the steps are defined, we need a way to run them. This is where the Temporal Workflow comes in.

The workflow has two main responsibilities - accept signals from the UI (form submissions) and iterate over the steps.

Here is the TakeawayWorkflow

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway\Workflow;

use App\Demo\Takeaway\Takeaway;
use App\Demo\Takeaway\TakeawayState;
use App\Demo\Takeaway\TakeawaySteps;
use App\Demo\Takeaway\Workflow\Activities\TakeawayActivitiesInterface;
use App\StepKit\StepCatalog;
use App\StepKit\StepEngine;
use App\StepKit\Workflow\Activities\NotifierActivitiesInterface;
use Generator;
use Temporal\Activity\ActivityOptions;
use Temporal\Common\RetryOptions;
use Temporal\Internal\Workflow\ActivityProxy;
use Temporal\Workflow;
use Temporal\Workflow\QueryMethod;
use Temporal\Workflow\SignalMethod;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;
use Vanta\Integration\Symfony\Temporal\Attribute\AssignWorker;

#[AssignWorker(name: 'default')]
#[WorkflowInterface]
class TakeawayWorkflow implements TakeawayWorkflowInterface
{
    private readonly object $activities;

    private readonly ActivityProxy|NotifierActivitiesInterface $notifier;

    private TakeawayState $state;

    public function __construct()
    {
        $this->activities = Workflow::newActivityStub(
            TakeawayActivitiesInterface::class,
            ActivityOptions::new()
                ->withStartToCloseTimeout('10 seconds')
                ->withRetryOptions(
                    RetryOptions::new()->withMaximumAttempts(2)
                )
        );

        $this->notifier = Workflow::newActivityStub(
            NotifierActivitiesInterface::class,
            ActivityOptions::new()
                ->withStartToCloseTimeout('5 seconds')
                ->withRetryOptions(
                    RetryOptions::new()->withMaximumAttempts(2)
                )
        );
    }

    #[WorkflowMethod]
    public function run(): Generator
    {
        $this->state = new TakeawayState();
        $this->state->workflowId = Workflow::getInfo()->execution->getID();
        $this->state->orderNumber = 'TA-' . strtoupper(substr($this->state->workflowId, 0, 6));

        // Set the initial step
        $this->state->currentStep = Takeaway::MENU->value;

        $engine = new StepEngine(
            new StepCatalog(TakeawaySteps::all()),
            $this->activities,
            $this->notifier
        );

        yield $engine->run($this->state);
    }

    #[SignalMethod]
    public function submitStep(string $stepName, mixed $payload): void
    {
        match ($stepName) {
            Takeaway::MENU->value => $this->state->menu = $payload,
            Takeaway::ADDRESS->value => $this->state->address = $payload,
            Takeaway::MAP->value => $this->state->mapConfirmed = (bool) $payload->confirmLocation,
            default => null,
        };
    }

    #[QueryMethod]
    public function getState(): TakeawayState
    {
        return $this->state;
    }
}

Enter fullscreen mode Exit fullscreen mode

The Engine

You might have noticed the StepEngine above. That's a generic and reusable class that forms part of the "library" I've built.

class StepEngine
{
    public function __construct(
        private readonly StepCatalog $catalog,
        private readonly object $activities,
        private readonly object $notifier
    ) {
    }

    public function run(FlowState $state): Generator
    {
        $current = $state->currentStep;

        while (true) {
            $def = $this->catalog->getByValue($current);
            $handler = new ($def->handlerClass)();

            // Update the UI via Mercure
            yield $this->notifier->notifyWorkflowUpdate(
                $state->workflowId,
                $current,
                ['state' => $state]
            );

            // Execute the Handler (This blocks if the handler calls await!)
            /** @var StepResult $result */
            $result = yield $handler->execute($state, $this->activities, $this->notifier);

            // Move to next step or finish
            if ($result->nextStep === null || $def->terminal) {
                break;
            }

            $current = $state->currentStep = $result->nextStep->value;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It does 3 things:

  1. Notifies the UI we have started a new step (triggering a re-render)
  2. Executes the handler for that step
  3. Repeat

Crucially, because the $state is passed during notification, the UI is always in sync with the worker. When the backend geocodes an address and updates the latitude/longitude, that data is immediately pushed to the frontend, allowing the Map component to render the pin correctly without the browser ever asking for it.

The Entry Point

We start with a standard Symfony Controller. Its only job is to spin up a new Temporal Workflow, generate a UUID, and redirect the user to the "live" view.

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway\Controller;

use App\Demo\Takeaway\Workflow\TakeawayWorkflow;
use Carbon\CarbonInterval;
use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Temporal\Client\WorkflowClientInterface;
use Temporal\Client\WorkflowOptions;

class TakeawayController extends AbstractController
{
    public function __construct(private readonly WorkflowClientInterface $workflowClient)
    {
    }

    #[Route('/demo/takeaway', name: 'demo_takeaway')]
    public function index(): Response
    {
        $workflowId = Uuid::uuid7()->toString();

        $workflow = $this->workflowClient->newWorkflowStub(
            TakeawayWorkflow::class,
            WorkflowOptions::new()
                ->withTaskQueue('default')
                ->withWorkflowExecutionTimeout(CarbonInterval::minutes(30))
                ->withWorkflowRunTimeout(CarbonInterval::minutes(30))
                ->withWorkflowId($workflowId)
        );

        $this->workflowClient->start($workflow);

        return $this->redirectToRoute('demo_takeaway_show', [
            'workflowId' => $workflowId,
        ]);
    }

    #[Route('/demo/takeaway/{workflowId}', name: 'demo_takeaway_show')]
    public function show(string $workflowId): Response
    {
        return $this->render('Takeaway/index.html.twig', [
            'workflowId' => $workflowId,
        ]);
    }
}

Enter fullscreen mode Exit fullscreen mode

The Twig template is equally simple. It just renders the Live Component, passing in the Workflow ID.

{% extends 'base.html.twig' %}

{% block title %}Takeaway Demo{% endblock %}

{% block body %}
    <div class="row justify-content-center">
        <div class="col-12 col-lg-8">
            <div class="mb-4 text-center">
                <h1 class="h3">Takeaway delivery</h1>
                <p class="text-muted mb-0">Pick a dish, confirm the drop-off, and watch the order progress live.</p>
            </div>
            {{ component('TakeawayWorkflowRunner', {
                workflowId: workflowId
            }) }}
        </div>
    </div>
{% endblock %}

Enter fullscreen mode Exit fullscreen mode

The Container

I abstracted the heavy lifting (Mercure subscriptions, state syncing) into a base class, so the specific Live Component for the Takeaway demo is incredibly thin. It simply defines which Workflow and Step Catalog to use.

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway\Twig\Components;

use App\Demo\Takeaway\TakeawayState;
use App\Demo\Takeaway\TakeawaySteps;
use App\Demo\Takeaway\Workflow\TakeawayWorkflow;
use App\StepKit\FlowState;
use App\StepKit\Twig\Components\AbstractWorkflowRunner;
use InvalidArgumentException;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;

#[AsLiveComponent('TakeawayWorkflowRunner', template: 'components/WorkflowRunner.html.twig')]
class TakeawayWorkflowRunner extends AbstractWorkflowRunner
{
    #[LiveProp(writable: true)]
    public ?TakeawayState $state = null;

    protected function getWorkflowClass(): string
    {
        return TakeawayWorkflow::class;
    }

    protected function getStepsClass(): string
    {
        return TakeawaySteps::class;
    }

    protected function readState(): ?FlowState
    {
        return $this->state;
    }

    protected function writeState(FlowState $state): void
    {
        if (!$state instanceof TakeawayState) {
            throw new InvalidArgumentException('Expected TakeawayState for workflow state.');
        }

        $this->state = $state;
    }
}

Enter fullscreen mode Exit fullscreen mode

The template for this component acts as the Dynamic Container. It listens to the Mercure stream (via the workflow-stream stimulus controller) and renders the child component dictated by the current step.

<div {{ attributes.defaults({
    'class': 'workflow-runner',
    'data-controller': 'live workflow-stream',
    'data-workflow-stream-workflow-id-value': workflowId,
    'data-workflow-stream-mercure-url-value': mercurePublicUrl,
}) }}
        id="workflow-router-{{ workflowId }}"
>

    <div class="card shadow-sm">
        <div class="card-body">
            {% if this.childComponentName %}
                {{ component(this.childComponentName, {
                    workflowId: this.workflowId,
                    stepName: currentStepId,
                    state: state,
                    key: 'step_' ~ currentStepId
                }) }}
            {% endif %}
        </div>
    </div>

</div>

Enter fullscreen mode Exit fullscreen mode

The Abstraction

Under the hood, the AbstractWorkflowRunner handles two critical jobs:

  1. Mount - when the page loads it queries Temporal for the initial state.
  2. Render - It looks up the current step in the Catalog to determine which child component to show.
<?php

declare(strict_types=1);

namespace App\StepKit\Twig\Components;

use App\StepKit\FlowState;
use App\StepKit\StepCatalog;
use InvalidArgumentException;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Temporal\Client\WorkflowClientInterface;

abstract class AbstractWorkflowRunner
{
    use DefaultActionTrait;

    /**
     * @var non-empty-string
     */
    #[LiveProp]
    public string $workflowId;

    #[LiveProp(writable: true)]
    public ?string $currentStepId = null;

    public function __construct(public string $mercurePublicUrl, private readonly WorkflowClientInterface $workflowClient)
    {
    }

    public function mount(string $workflowId): void
    {
        $this->workflowId = $workflowId;

        $this->writeState(
            $this->workflowClient
                ->newRunningWorkflowStub($this->getWorkflowClass(), $this->workflowId)
                ->getState()
        );

        $this->currentStepId = $this->readState()?->currentStep;
    }

    public function getChildComponentName(): ?string
    {
        $state = $this->readState();

        if (!$state instanceof FlowState || !$state->currentStep) {
            return null;
        }

        $catalog = $this->getCatalog();

        return $catalog->getByValue($this->currentStepId ?? $state->currentStep)
            ->liveComponent;
    }

    // ... abstract methods to get catalog/classes
}
Enter fullscreen mode Exit fullscreen mode

The Interaction

Finally, we need to handle user input. Instead of writing a custom controller for every step, I created a generic AbstractWorkflowForm.

This makes your implementation simple.

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway\Twig\Components;

use App\Demo\Takeaway\TakeawayState;
use App\Demo\Takeaway\TakeawaySteps;
use App\Demo\Takeaway\Workflow\TakeawayWorkflow;
use App\StepKit\FlowState;
use App\StepKit\Twig\Components\AbstractWorkflowForm;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('TakeawayForm', template: 'components/Form.html.twig')]
class TakeawayForm extends AbstractWorkflowForm
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;

    #[LiveProp(writable: false)]
    public ?TakeawayState $state = null;

    protected function getWorkflowClass(): string
    {
        return TakeawayWorkflow::class;
    }

    protected function getStepsClass(): string
    {
        return TakeawaySteps::class;
    }

    protected function readState(): ?FlowState
    {
        return $this->state;
    }
}

Enter fullscreen mode Exit fullscreen mode

AbstractWorkflowForm uses the StepCatalog to identify the correct FormType and DTO for the current step. When save() is triggered, it validates the data and sends a submitStep signal to the Temporal Workflow.

<?php

declare(strict_types=1);

namespace App\StepKit\Twig\Components;

use App\StepKit\FlowState;
use App\StepKit\StepCatalog;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Temporal\Client\WorkflowClientInterface;

abstract class AbstractWorkflowForm extends AbstractController
{
    use ComponentWithFormTrait;
    use DefaultActionTrait;

    /**
     * @var non-empty-string
     */
    #[LiveProp]
    public string $workflowId;

    public function __construct(
        private readonly FormFactoryInterface $formFactory,
        private readonly WorkflowClientInterface $workflowClient,
    ) {
    }

    #[LiveAction]
    public function save(): void
    {
        $this->submitForm();
        $form = $this->getForm();

        if (!$form->isValid()) {
            return;
        }

        $state = $this->readState();

        if (!$state instanceof FlowState || !is_string($state->currentStep)) {
            return;
        }

        $catalog = $this->getCatalog();
        $def = $catalog->getByValue($state->currentStep);

        $this->workflowClient->newRunningWorkflowStub($this->getWorkflowClass(), $this->workflowId)
            ->submitStep($def->id->value, $form->getData());
    }

    protected function instantiateForm(): FormInterface
    {
        $state = $this->readState();

        if (!$state instanceof FlowState || !is_string($state->currentStep)) {
            return $this->formFactory->createNamedBuilder('form')->getForm();
        }

        $catalog = $this->getCatalog();
        $def = $catalog->getByValue($state->currentStep);

        $formType = $def->formType;
        $dto = $def->dtoClass;

        if ($formType === null || $dto === null) {
            return $this->formFactory->createNamedBuilder('form')->getForm();
        }

        return $this->formFactory->create(
            type: $formType,
            data: new $dto(),
        );
    }
Enter fullscreen mode Exit fullscreen mode

If all you want to handle is a standard multi-step form, the generic AbstractWorkflowForm handles everything.

Real applications rarely fit into neat little boxes. The Takeaway example, for instance, requires a map to confirm the geocoded location.

Because we are just using Live Components, we can easily mix in other Symfony UX features. Here is the TakeawayMapConfirm component. It extends our workflow abstraction but adds ComponentWithMapTrait to render the interactive map using Symfony UX Map.

<?php

declare(strict_types=1);

namespace App\Demo\Takeaway\Twig\Components;

use App\Demo\Takeaway\TakeawayState;
use App\Demo\Takeaway\TakeawaySteps;
use App\Demo\Takeaway\Workflow\TakeawayWorkflow;
use App\StepKit\FlowState;
use App\StepKit\Twig\Components\AbstractWorkflowForm;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\Map\Live\ComponentWithMapTrait;
use Symfony\UX\Map\Map;
use Symfony\UX\Map\Marker;
use Symfony\UX\Map\Point;

#[AsLiveComponent('TakeawayMapConfirm', template: 'components/takeaway/MapConfirm.html.twig')]
class TakeawayMapConfirm extends AbstractWorkflowForm
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;
    use ComponentWithMapTrait;

    #[LiveProp(writable: false)]
    public ?TakeawayState $state = null;

    protected function instantiateMap(): Map
    {
        $latitude = $this->state?->latitude ?? 51.5074;
        $longitude = $this->state?->longitude ?? -0.1278;
        $point = new Point($latitude, $longitude);

        return new Map()
            ->center($point)
            ->zoom(15)
            ->addMarker(new Marker($point, 'Drop-off'))
            ->fitBoundsToMarkers();
    }

    protected function getWorkflowClass(): string
    {
        return TakeawayWorkflow::class;
    }

    protected function getStepsClass(): string
    {
        return TakeawaySteps::class;
    }

    protected function readState(): ?FlowState
    {
        return $this->state;
    }
}
Enter fullscreen mode Exit fullscreen mode

The Javascript

I mentioned it briefly earlier with the WorkflowRunner. There's a small amount of Javascript that links Mercure to the UX components.

This updates the state and currentStepId properties of the WorkflowRunner and triggers a render on the component, updating the UI.

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static values = {
        workflowId: String,
        mercureUrl: String,
    }

    connect() {
        console.log('Workflow stream controller connected!');
        this.connectToMercure();
    }

    connectToMercure() {
        const url = new URL(this.mercureUrlValue);
        url.searchParams.append('topic', `workflow/${this.workflowIdValue}`);

        const urlString = url.toString();
        console.log('Connecting to Mercure:', urlString);

        // EventSource needs a string, not a URL object
        this.eventSource = new EventSource(urlString);

        this.eventSource.onopen = () => {
            console.log('Mercure connection opened');
        };

        this.eventSource.onmessage = (event) => {
            console.log('Mercure event:', event.data);
            const data = JSON.parse(event.data);
            this.handleUpdate(data);
        };

        this.eventSource.onerror = (error) => {
            console.error('Mercure error:', error);
            console.log('ReadyState:', this.eventSource.readyState);
        };
    }

    async handleUpdate(data) {
        const { getComponent } = await import('@symfony/ux-live-component');
        const component = await getComponent(this.element);

        if (data.payload?.state) {
            component.set('state', data.payload.state, false);
        }

        if (data.step) {
            component.set('currentStepId', data.step, false);
        }

        await component.render();
    }

    disconnect() {
        if (this.eventSource) {
            console.log('Closing Mercure connection');
            this.eventSource.close();
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Orchestrated UI with Symfony UX and Mercure

The Flow

Now you know that the StepCatalog defines each step, how it's displayed and how it's handled. Forms when submitted will update properties on the state. And that Mercure will push the updated state on every step. Seeing the handler code laid out is the final piece of the puzzle.

// Menu Step
yield Workflow::await(
    static fn(): bool => $state->menu !== null // wait for menu form to be submitted
);

return StepResult::next(Takeaway::ADDRESS);
Enter fullscreen mode Exit fullscreen mode
// Address Step
yield Workflow::await(
    static fn(): bool => $state->address !== null // wait for address form to be submitted
);

$location = yield $activity->geocodeAddress($state->address);

// Append coordinates to the state - mercure will push this back to the UI
if ($location?->getCoordinates() !== null) {
    $state->latitude = $location->getCoordinates()->getLatitude();
    $state->longitude = $location->getCoordinates()->getLongitude();
}

return StepResult::next(Takeaway::MAP);
Enter fullscreen mode Exit fullscreen mode
// Map Step
yield Workflow::await(
  static fn(): bool => $state->mapConfirmed // wait for the map location to be confirmed
);

return StepResult::next(Takeaway::PREPARE_FOOD);
Enter fullscreen mode Exit fullscreen mode
// Prepare Food Step
yield Workflow::timer(DateInterval::createFromDateString('20 seconds')); // wait for 20 seconds to "simulate" something happening

return StepResult::next(Takeaway::DELIVER_FOOD);
Enter fullscreen mode Exit fullscreen mode
// Deliver Food Step
yield Workflow::timer(DateInterval::createFromDateString('25 seconds')); // wait for 25 seconds to "simulate" something happening

return StepResult::next(Takeaway::DELIVERED);
Enter fullscreen mode Exit fullscreen mode
// Complete Step
yield from [];

return StepResult::end(); // fin
Enter fullscreen mode Exit fullscreen mode

Conclusion

By combining Temporal, Symfony UX and Mercure, we have built a system that feels and acts like a modern Single Page Application (SPA) but writes like a standard PHP application.

Instead of managing state in Redux, passing JSON back and forth, and writing complex frontend routing logic, we simply let the workflow drive.

Whilst the Takeaway example is relatively simple - hopefully it demonstrates the potential of this pattern in building complex, state heavy web apps.

The repository containing what I'm tentatively naming StepKit and some examples including the one featured in this article is below.

I borrowed the other 2 examples from - https://github.com/yceruto/formflow-demo/ so thanks to yceruto.

https://github.com/clegginabox/stepkit

I’d love to hear your thoughts on the pattern and the naming. Also, as this is my first deep dive into Symfony UX, I am sure there are optimizations I've missed. If you spot a cleaner way to handle the Live Components or the wiring, please let me know.

As you've made it this far - a little demo of the other project I mentioned at the beginning of this post.

Orchestrated UI with Symfony UX and Mercure

Ddrop a comment if you have questions, suggestions, or if you’ve seen something similar implemented elsewhere!

Until next time 👋

Top comments (0)