Files
myep/REFACTORING_PARTICIPANT_CARDS.md
T

50 KiB

Participant Card View Refactoring Plan

Overview

Transform create (step 2) and edit flows from accordion-style "all forms at once" to a card overview with lazy-loaded individual participant forms. This addresses UX and performance issues with large groups (50+ participants).

Status: Planning Complete - Ready for Implementation (SIMPLIFIED ARCHITECTURE - 2025-01-14)

Architecture Revision (2025-01-14)

Major Simplification: Removed wrapper forms and DTOs!

What Changed:

  • Removed: ParticipantFormDto - not needed
  • Removed: ParticipantFormType wrapper - not needed
  • Simplified: Pass BookingDto via form options (booking_context)
  • Simplified: Reuse BookingParticipantType directly
  • Simplified: Field handlers called manually in controller

Why: The initial plan had unnecessary abstraction layers. We realized we can simply pass the BookingDto as a form option instead of wrapping everything. This is cleaner, more explicit, and easier to understand.

Implementation Discovery: Field Handler Mode Awareness (2025-01-14)

Problem Encountered: When promoting participant forms to standalone (autonomous) forms in the card flow, field handlers needed to be called from within BookingParticipantType's PRE_SUBMIT event. This created a critical issue in edit mode:

  • The BPN API does not return insurance data for privacy/security reasons
  • In edit mode, the insurance field handler would see missing insurance data in submitted forms
  • The handler would incorrectly interpret this as "user wants to clear insurance"
  • Result: Insurance data would be lost during edit operations

Root Cause: Field handlers had no way to distinguish between:

  1. Create mode: Missing data = user didn't select anything (clear it)
  2. Edit mode: Missing data = API didn't provide it (preserve existing value)

Solution Implemented: Added mode awareness to the field handler system:

// Interface change
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool;

// Insurance handler implementation
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
    // Skip processing in edit mode - API doesn't return insurance data
    if ($mode === BookingDto::MODE_EDIT) {
        return false;
    }

    // Process normally in create mode
    return true;
}

Architecture Changes:

  • Added $mode parameter to ParticipantFieldHandlerInterface::shouldProcess()
  • Updated AbstractParticipantFieldHandler with mode parameter
  • Added helper methods: isEditMode(), isCreateMode() for future use
  • Updated ParticipantFieldHandlerRegistry to pass $bookingDto->mode to handlers
  • Updated all 13+ field handlers to accept mode parameter
  • Insurance handler skips processing entirely in edit mode
  • All other handlers continue processing normally in both modes

Why Only Insurance Needs Special Treatment:

  • Insurance: API doesn't return data → must skip processing in edit mode
  • Services (ski pass, rentals, courses): API returns data → process normally
  • Transportation: API returns data → process normally
  • Room assignments: API returns data → process normally
  • All other fields: API returns data → process normally

Benefits:

  • Insurance data preserved correctly in edit mode
  • Explicit mode-aware behavior (no implicit assumptions)
  • Clean separation: only handlers that need it check mode
  • Future-proof: easy to add mode-specific logic to other handlers if needed
  • Tests updated to verify edit mode behavior

Implementation Status: COMPLETED (2025-01-14)

Goals

  • Performance: Lazy load forms on demand instead of rendering all at once
  • UX: Cleaner overview with card-based navigation
  • Scalability: Handle 50+ participant bookings efficiently
  • Code Reuse: Maximize shared code between create and edit flows
  • Safe Migration: Keep old flows intact during development

Architecture Decisions

Decision Choice Rationale
Form Rendering Replace entire content area (Option C) Best for mobile, prevents concurrency issues
Form Submission Explicit "Speichern" button Not all fields trigger refresh, cleaner UX
Card Content Name, room, price only Minimal, focused information
Validation On form submission only No card-level indicators needed
Card Price Updates Full re-render on return to cards Cards not visible during form editing
Room Assignment Keep in participant form Existing behavior maintained
Migration Strategy Separate controllers (-v2 routes) Safe parallel development, easy comparison
Lazy Loading Forms loaded via HTMX on demand Critical for performance with 50+ participants
BookingDto Context Pass via form options Simple, explicit, no wrappers needed

Key Simplification

No wrapper forms or DTOs needed! We pass BookingDto directly via form options:

// Controller creates form with explicit context
$form = $this->createForm(BookingParticipantType::class, $participant, [
    'booking_context' => $bookingDto,  // Passed explicitly
    'edit_mode' => false,
]);

Benefits:

  • No ParticipantFormDto wrapper
  • No ParticipantFormType wrapper
  • Reuse existing BookingParticipantType directly
  • Explicit data flow (no "magic" form tree traversal)
  • Field handlers called manually in controller (like current Step 2)
  • Clean, maintainable, easy to understand

Phase 1: Shared Foundation

1.1 Create ParticipantCardDataService

File: src/Service/ParticipantCardDataService.php

Purpose: Extract card display data from BookingDto

Methods:

/**
 * Get card data for a single participant
 *
 * @return array{name: string, roomName: string, price: string}
 */
public function getCardData(BookingDto $bookingDto, int $index): array

/**
 * Get card data for all participants
 *
 * @return array<int, array{name: string, roomName: string, price: string}>
 */
public function getAllCardsData(BookingDto $bookingDto): array

Implementation Details:

  • Name: $participant->firstName . ' ' . $participant->lastName or fallback to "Teilnehmer {index+1}"
  • Room: Look up from $bookingDto->travel->getRoomById($participant->assignedRoomId)
  • Price: Use BookingPriceCalculatorService::calculateAllParticipantIndividualPrices(), format as number_format($price, 2, ',', '.') . ' €'

Dependencies:

  • BookingPriceCalculatorService (existing)
  • Access to Travel model for room lookups

Test Coverage:

  • Unit test: Verify name fallback for participants without names
  • Unit test: Verify price formatting
  • Unit test: Verify room name lookup
  • Unit test: Handle missing room assignment gracefully

Todos:

  • Create service class with DI configuration
  • Implement getCardData() method
  • Implement getAllCardsData() method
  • Write unit tests
  • Handle edge cases (missing room, missing names)

1.2 Extend ParticipantFieldHandlerRegistry

File: src/Form/Service/ParticipantFieldHandlerRegistry.php

New Method:

/**
 * Process field handlers for a single participant
 *
 * @param array<string, mixed> $participantData Submitted data for one participant
 * @param BookingDto $bookingDto The booking DTO to update
 * @param int $participantIndex Index of participant to process
 */
public function processFieldsForParticipant(
    array $participantData,
    BookingDto $bookingDto,
    int $participantIndex
): void

Implementation:

  • Extract loop body from existing processFields() method
  • Apply all handlers to single participant in dependency order
  • Reuse existing handler sorting and execution logic

Todos:

  • Add processFieldsForParticipant() method to registry
  • Extract handler execution logic from loop
  • Maintain dependency order execution
  • Test with existing field handlers
  • Verify field sync logic works for single participant

1.3 Update BookingParticipantType to Accept booking_context Option

File: src/Form/BookingParticipantType.php

Purpose: Make form type autonomous - it processes its own field handlers when used standalone

Why This Simplification: In the card flow, there's no parent form to orchestrate field handler execution. The form type becomes self-contained and autonomous - it receives BookingDto via options and processes its own field handlers in PRE_SUBMIT.

Architecture Benefits:

  • No wrapper forms needed
  • No extra DTOs needed
  • No parent form needed
  • Explicit data flow via options
  • Field handlers called automatically in PRE_SUBMIT
  • Controller stays thin - just creates form and handles result
  • Form type is responsible for its own data processing

Key Insight: We're promoting the child form to be in charge. It's no longer a dumb child controlled by a parent - it's a smart, autonomous form that handles everything itself.

Modification to __construct() - Add Field Handler Registry:

public function __construct(
    private readonly FieldOptionsProviderInterface $fieldOptionsProvider,
    private readonly CreateFieldStateProvider $createFieldStateProvider,
    private readonly EditFieldStateProvider $editFieldStateProvider,
    private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry,  // NEW: for autonomous processing
) {
}

Modification to configureOptions():

public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults([
        'data_class' => ParticipantDto::class,
        'selected_rooms' => [],
        'edit_mode' => false,
        'booking_context' => null,  // NEW: Optional BookingDto for card flows
    ]);

    $resolver->setAllowedTypes('selected_rooms', 'array');
    $resolver->setAllowedTypes('edit_mode', 'bool');
    $resolver->setAllowedTypes('booking_context', ['null', BookingDto::class]);
}

Modification to buildForm():

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    // Select field state provider based on edit_mode option
    $this->fieldStateProvider = $options['edit_mode']
        ? $this->editFieldStateProvider
        : $this->createFieldStateProvider;

    // Capture booking context for use in event listeners
    $bookingContext = $options['booking_context'];

    $builder
        ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($bookingContext) {
            $this->onPreSetData($event, $bookingContext);
        })
        ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($bookingContext) {
            // Process field handlers FIRST (before form binding and validation)
            // This ensures data is cleaned before Symfony processes it
            if (null !== $bookingContext) {
                $this->processFieldHandlers($event, $bookingContext);
            }

            // Then rebuild fields with updated states
            $this->onPreSubmit($event, $bookingContext);
        });
}

/**
 * Process field handlers for this participant.
 *
 * Field handlers are executed in PRE_SUBMIT to clean and transform data
 * before Symfony binds it to the form. This matches the pattern used in
 * the old BookingCreateStep2Type parent form.
 */
private function processFieldHandlers(FormEvent $event, BookingDto $bookingContext): void
{
    $form = $event->getForm();
    $submittedData = $event->getData();

    if (false === is_array($submittedData)) {
        return;
    }

    /** @var ParticipantDto $participant */
    $participant = $form->getData();

    if (null === $participant || false === property_exists($participant, 'index')) {
        return;
    }

    // Process all field handlers for this participant in dependency order
    $this->fieldHandlerRegistry->processFieldsForParticipant(
        $submittedData,
        $bookingContext,
        $participant->index
    );
}

private function onPreSetData(FormEvent $event, ?BookingDto $bookingContext): void
{
    /** @var ParticipantDto|null $participantData */
    $participantData = $event->getData();
    $form = $event->getForm();

    if (null === $participantData) {
        return;
    }

    // Card flow: BookingDto passed via options
    // Accordion flow (if we had one): traverse form tree
    $bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form);

    if (null === $bookingDto) {
        return;
    }

    // Add base fields with states applied
    $this->addBaseFields($form, $bookingDto, $participantData->index);

    // Add dynamic fields
    $this->addDynamicFields($form, $bookingDto, $participantData->index);
}

private function onPreSubmit(FormEvent $event, ?BookingDto $bookingContext): void
{
    $submittedData = $event->getData();
    $form = $event->getForm();

    if (false === is_array($submittedData)) {
        return;
    }

    // Card flow: BookingDto passed via options
    // Accordion flow (if we had one): traverse form tree
    $bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form);

    if (null === $bookingDto) {
        return;
    }

    // Get participant index from form data
    $participantData = $form->getData();
    if (null === $participantData || false === property_exists($participantData, 'index')) {
        return;
    }

    // Rebuild all fields with updated states based on submitted data
    $this->rebuildFieldsWithStates($form, $bookingDto, $participantData->index, $submittedData);
}

Usage in Controllers:

// Card flow: Pass booking context explicitly
$form = $this->createForm(BookingParticipantType::class, $participant, [
    'booking_context' => $bookingDto,
    'edit_mode' => false,
]);

Todos:

  • Add ParticipantFieldHandlerRegistry to constructor (DI)
  • Add booking_context option to configureOptions()
  • Update buildForm() to capture booking_context and add PRE_SUBMIT listener
  • Add processFieldHandlers() private method to call registry
  • Update onPreSetData() to accept and use $bookingContext parameter
  • Update onPreSubmit() to accept and use $bookingContext parameter
  • Test with card flow (booking_context provided)
  • Verify field handlers execute automatically
  • Verify field state providers work correctly

1.4 Create ParticipantCardFlowTrait

File: src/Controller/Booking/ParticipantCardFlowTrait.php

Purpose: Share common controller logic between create and edit flows

Methods:

/**
 * Load BookingDto from session or throw exception
 */
private function loadBookingDtoOrFail(Request $request, string $mode): BookingDto

/**
 * Generate card data for all participants
 */
private function generateAllCardsData(BookingDto $bookingDto): array

/**
 * Create form for single participant
 */
private function createParticipantForm(
    BookingDto $bookingDto,
    int $index,
    array $options = []
): FormInterface

/**
 * Calculate summary data (pricing, room counts, etc.)
 */
private function calculateSummaryData(BookingDto $bookingDto): array

/**
 * Process single participant form refresh
 */
private function handleParticipantRefresh(
    Request $request,
    BookingDto $bookingDto,
    int $index,
    string $refreshRouteName
): Response

Todos:

  • Create trait file
  • Implement loadBookingDtoOrFail()
  • Implement generateAllCardsData() using ParticipantCardDataService
  • Implement createParticipantForm()
  • Implement calculateSummaryData()
  • Implement handleParticipantRefresh() with OOB swap logic
  • Add type hints and PHPDoc

Phase 2: Create Flow (Step 2 Refactored)

2.1 Create CreateStep2RefactoredController

File: src/Controller/Booking/CreateStep2RefactoredController.php

Routes:

Method Route Name Purpose
GET/POST /bookings/create/participants-v2 app_booking_create_step_2_v2_cards Display card grid
GET/POST /bookings/create/participants-v2/{index} app_booking_create_step_2_v2_participant Show/submit participant form
POST /bookings/create/participants-v2/{index}/refresh app_booking_create_step_2_v2_participant_refresh HTMX refresh without validation

Controller Structure:

class CreateStep2RefactoredController extends AbstractController
{
    use BookingCreateTrait;
    use BookingExceptionHandlerTrait;
    use HxTrait;
    use ParticipantCardFlowTrait;

    public function __construct(
        private readonly BookingService $bookingService,
        private readonly BookingPriceCalculatorService $priceCalculator,
        private readonly TravelDataService $travelDataService,
        private readonly RoomAssignmentService $roomAssignmentService,
        private readonly ParticipantCardDataService $participantCardService,
        private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry,
    ) {
    }
}

Action: showCards(Request $request): Response

Implementation steps:

  1. Load BookingDto from session (reuse getOrCreateBookingCreateDto())
  2. Enrich with fresh availability data
  3. Validate step access
  4. Ensure correct number of participants
  5. Auto-assign rooms if needed
  6. Preselect mandatory services
  7. Save BookingDto to session
  8. Generate all cards data via ParticipantCardDataService
  9. Calculate summary data
  10. Render create_step_2_v2_cards.html.twig

Action: editParticipant(int $index, Request $request): Response

Implementation steps:

  1. Load BookingDto from session
  2. Validate participant index
  3. Create form for $bookingDto->participants[$index] using BookingParticipantType with booking_context option:
    $form = $this->createForm(BookingParticipantType::class, $participant, [
        'booking_context' => $bookingDto,
        'edit_mode' => false,
        'validation_groups' => ['booking_create_step_2'],
    ]);
    
  4. $form->handleRequest($request)
    • Note: Field handlers are called automatically by the form in PRE_SUBMIT
  5. If submitted and valid:
    • Save BookingDto to session
    • HTMX redirect to cards view
  6. Calculate summary data for sidebar
  7. Render _participant_form_standalone.html.twig with form and summary

Action: refreshParticipantForm(int $index, Request $request): Response

Implementation steps:

  1. Load BookingDto from session
  2. Enrich with fresh availability data
  3. Create form with booking_context option and validation_groups: false:
    $form = $this->createForm(BookingParticipantType::class, $participant, [
        'booking_context' => $bookingDto,
        'edit_mode' => false,
        'validation_groups' => false,
    ]);
    
  4. $form->handleRequest($request)
  5. Extract participant data from submitted form
  6. Process field handlers via processFieldsForParticipant()
  7. Preselect mandatory services
  8. Save BookingDto to session
  9. Collect notifications from participant DTO
  10. Calculate summary data
  11. Render form + sidebar using htmxOobResponse() with blocks:
    • participant_form (target)
    • booking_summary (OOB swap)
  12. Add notifications to HX-Trigger header if present

Todos:

  • Create controller class with DI
  • Implement showCards() action
  • Implement editParticipant() action
  • Implement refreshParticipantForm() action
  • Add route annotations
  • Handle edge cases (invalid index, session expiry)
  • Add error handling and logging

2.2 Templates for Create Flow

Main Template: templates/booking/create_step_2_v2_cards.html.twig

Structure:

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

{% block content %}
    {% include '_partials/_flashes.html.twig' %}
    <h1>Neue Buchung</h1>

    <div class="grid grid-cols-3 gap-8">
        {# Main content area - cards grid #}
        <div id="main-content" class="col-span-2">
            <h2>Teilnehmer</h2>

            <div id="participant-cards-grid" class="space-y-4">
                {% for participant in bookingDto.participants %}
                    {% include 'booking/_participant_card.html.twig' with {
                        'cardData': cardsData[loop.index0],
                        'index': loop.index0,
                        'mode': 'create'
                    } %}
                {% endfor %}
            </div>

            <div class="flex justify-between mt-8">
                <a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary">
                    Zurück
                </a>
                <button type="submit" class="button bg-button bg-button--secondary">
                    Weiter
                </button>
            </div>
        </div>

        {# Sidebar summary #}
        {% block booking_summary %}
            <div id="booking-summary">
                {% include 'booking/_summary.html.twig' with {
                    'bookingCreateDto': bookingDto,
                    'participantCount': participantsCount,
                    'groupedSelectedRooms': groupedSelectedRooms,
                    'assignmentCounts': assignmentCounts
                } %}
            </div>
        {% endblock %}
    </div>
{% endblock %}

Todos:

  • Create main template file
  • Add grid layout structure
  • Include card loop
  • Add navigation buttons
  • Include sidebar summary

Card Partial: templates/booking/_participant_card.html.twig

Structure:

{# Compact participant card with name, room, price, and edit button #}
<div id="participant-card-{{ index }}" class="border rounded p-4 flex justify-between items-center">
    <div>
        <h3 class="font-semibold">{{ cardData.name }}</h3>
        <p class="text-sm text-gray-600">{{ cardData.roomName }}</p>
    </div>
    <div class="flex items-center gap-4">
        <span class="font-medium">{{ cardData.price }}</span>
        <button type="button"
                class="button bg-button bg-button--secondary"
                hx-get="{{ path('app_booking_create_step_2_v2_participant', {index: index}) }}"
                hx-target="#main-content"
                hx-swap="innerHTML">
            Bearbeiten
        </button>
    </div>
</div>

Features:

  • Compact layout
  • Name (or "Teilnehmer X" fallback)
  • Room name
  • Price formatted as "450,00 €"
  • "Bearbeiten" button with HTMX attributes
  • Target replaces entire #main-content area

Todos:

  • Create card template
  • Add HTMX attributes for navigation
  • Style with TailwindCSS
  • Test responsive layout
  • Handle long names gracefully

Form Standalone: templates/booking/_participant_form_standalone.html.twig

Structure:

{# Standalone participant form view (replaces main content area) #}
<div id="participant-form-view">
    <h2>{{ participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}</h2>

    {{ form_start(form, {
        'attr': {
            'novalidate': 'novalidate',
            'hx-post': path('app_booking_create_step_2_v2_participant', {index: participantIndex}),
            'hx-target': '#main-content',
            'hx-swap': 'innerHTML'
        }
    }) }}

    {% block participant_form %}
        <div id="participant-form" class="space-y-4">
            {# Personal data section #}
            <div class="grid grid-cols-2 gap-4">
                {{ form_row(form.firstName) }}
                {{ form_row(form.lastName) }}
                {{ form_row(form.dateOfBirth, {
                    'attr': {
                        'hx-trigger': 'change',
                        'hx-post': path('app_booking_create_step_2_v2_participant_refresh', {index: participantIndex}),
                        'hx-swap': 'none'
                    }
                }) }}
                {{ form_row(form.gender) }}
                {{ form_row(form.nationality) }}
            </div>

            {# Contact information #}
            <div class="grid grid-cols-2 gap-4">
                {{ form_row(form.email) }}
                {{ form_row(form.mobile) }}
            </div>

            {# Address #}
            {% if form.address is defined %}
                <div class="grid grid-cols-2 gap-4">
                    {{ form_row(form.address.street) }}
                    {{ form_row(form.address.postCode) }}
                    {{ form_row(form.address.city) }}
                    {{ form_row(form.address.country) }}
                </div>
            {% endif %}

            {# Body dimensions #}
            {% if form.bodyDimensions is defined %}
                <div class="grid grid-cols-2 gap-4">
                    {{ form_row(form.bodyDimensions.height) }}
                    {{ form_row(form.bodyDimensions.shoeSize) }}
                    {{ form_row(form.bodyDimensions.weight) }}
                </div>
            {% endif %}

            {# Room assignment #}
            <div class="grid grid-cols-2 gap-4">
                {{ form_row(form.assignedRoomId, {
                    'attr': {
                        'hx-trigger': 'change',
                        'hx-post': path('app_booking_create_step_2_v2_participant_refresh', {index: participantIndex}),
                        'hx-swap': 'none'
                    }
                }) }}
                {% if form.remarksRoom is defined %}
                    {{ form_row(form.remarksRoom) }}
                {% endif %}
            </div>

            {# Service selection - same structure as current templates #}
            {# Age eligibility checks, conditional field rendering, etc. #}
            {# Transportation, insurance, etc. #}
        </div>
    {% endblock %}

    <div class="flex gap-4 mt-8">
        <button type="button"
                class="button bg-button bg-button--secondary"
                hx-get="{{ path('app_booking_create_step_2_v2_cards') }}"
                hx-target="#main-content"
                hx-swap="innerHTML">
            Abbrechen
        </button>
        <button type="submit" class="button bg-button bg-button--secondary">
            Speicheren
        </button>
    </div>

    {{ form_rest(form) }}
    {{ form_end(form) }}
</div>

Key Features:

  • Reuses existing form field structure from create_step_2.html.twig
  • HTMX attributes on fields that trigger refresh
  • "Abbrechen" returns to cards (GET request, no save)
  • "Speichern" submits form (POST request, validation, save on success)
  • Form action posts to same route (Symfony convention)

Todos:

  • Create form template
  • Copy form field structure from current template
  • Add HTMX attributes for refresh triggers
  • Add navigation buttons (Abbrechen/Speichern)
  • Test all conditional field logic
  • Ensure age eligibility checks work
  • Verify field state providers work correctly

Phase 3: Edit Flow Refactored

3.1 Create EditRefactoredController

File: src/Controller/Booking/EditRefactoredController.php

Routes:

Method Route Name Purpose
GET/POST /bookings/{id}/edit-v2 app_booking_edit_v2_cards Display card grid
GET/POST /bookings/{id}/edit-v2/participants/{index} app_booking_edit_v2_participant Show/submit participant form
POST /bookings/{id}/edit-v2/participants/{index}/refresh app_booking_edit_v2_participant_refresh HTMX refresh
POST /bookings/{id}/edit-v2/reload app_booking_edit_v2_reload Discard changes, reload from API

Differences from Create Flow:

  • Load booking data from API on first visit (via BookingDataTrait)
  • Store in session with MODE_EDIT
  • Handle canceled participants (status 'S'):
    • Show "storniert" badge on card
    • Disable "Bearbeiten" button
    • Show surcharges info when clicked
  • Final submission calls ApiClient::updateBooking() instead of navigating to step 3
  • Apply mutability constraints via EditFieldStateProvider

Controller Structure:

class EditRefactoredController extends AbstractController
{
    use BookingDataTrait;
    use HxTrait;
    use ParticipantCardFlowTrait;

    public function __construct(
        private readonly ApiClient $apiClient,
        private readonly BookingDataProcessor $bookingDataProcessor,
        private readonly TravelDataService $travelDataService,
        private readonly BookingService $bookingService,
        private readonly BookingPriceCalculatorService $priceCalculator,
        private readonly ParticipantCardDataService $participantCardService,
        private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry,
        private readonly CacheInterface $cache,
        private readonly Security $security,
        private readonly Crypt $crypt,
        private readonly LoggerInterface $logger,
    ) {
    }
}

Action Implementations: Similar to create flow, with edit-specific logic

Todos:

  • Create controller class with DI
  • Implement showCards() action (load from API on first visit)
  • Implement editParticipant() action (handle canceled participants)
  • Implement refreshParticipantForm() action (apply mutability constraints)
  • Implement reloadFromApi() action (discard session changes)
  • Add route annotations
  • Handle edit-specific edge cases
  • Test API integration

3.2 Templates for Edit Flow

Main Template: templates/booking/edit_v2_cards.html.twig

Similar to create flow, with differences:

  • Header shows "Buchung bearbeiten"
  • "Reload" button to discard changes
  • Different navigation (Zurück to bookings list, Aktualisieren to save)
  • Final submit button calls API update

Card Template: Reuse _participant_card.html.twig with mode parameter

  • Add "storniert" badge for canceled participants
  • Disable button for canceled participants

Form Template: Reuse _participant_form_standalone.html.twig with mode parameter

  • EditFieldStateProvider applies mutability constraints
  • Show applicant address placeholders

Todos:

  • Create main template for edit
  • Add reload button with confirmation
  • Modify card template to handle canceled participants
  • Test form template with edit mode
  • Verify mutability constraints applied correctly

Phase 4: Integration & Testing

4.1 Functional Testing Checklist

Card View:

  • Card grid renders all participants correctly
  • Card names show participant names or "Teilnehmer X" fallback
  • Card room names display correctly
  • Card prices formatted as "450,00 €"
  • "Bearbeiten" button on each card
  • Clicking "Bearbeiten" loads participant form
  • Navigation buttons work (Zurück/Weiter or Zurück/Aktualisieren)

Participant Form:

  • Form loads for correct participant
  • All form fields render correctly
  • Field states applied (readonly, disabled, hidden, required)
  • Conditional fields appear/disappear based on data
  • Age eligibility checks work
  • Service fields populated correctly

HTMX Interactions:

  • Field changes trigger refresh endpoint
  • Form refreshes with updated data
  • Sidebar summary updates (OOB swap)
  • Notifications display via toast
  • "Abbrechen" returns to cards without saving
  • "Speichern" submits form with validation
  • Valid submission redirects to cards
  • Invalid submission shows validation errors

Field Handlers:

  • Date of birth enables age-dependent services
  • Room assignment updates counts
  • Ski pass selection filters rentals by duration
  • Rental selection shows insurance checkbox
  • Bulk insurance updates dependent participants
  • Transportation selection shows/hides pickup/parking
  • License plate field appears with parking selection
  • Insurance auto-reassignment on price changes
  • Rental clearing when ski pass changes

Data Consistency:

  • Session DTO updated correctly after form save
  • Session DTO updated correctly after refresh
  • Card data matches DTO after returning from form
  • Prices recalculated correctly
  • Room assignments preserved
  • Service selections preserved

Edit Flow Specific:

  • Booking data loads from API on first visit
  • Session stores data correctly (MODE_EDIT)
  • Canceled participants show badge
  • Canceled participants have disabled button
  • Mutability constraints applied
  • Final submission calls API correctly
  • Reload discards session changes

4.2 Performance Testing

Metrics to Measure:

  • Card grid load time with 50 participants: < 1 second
  • Participant form load time: < 200ms (lazy loading benefit)
  • Field refresh response time: < 500ms
  • Memory usage with 50 participants: reasonable
  • No N+1 queries in card data generation
  • Session size doesn't grow excessively

Performance Tests:

  • Create booking with 2-3 participants (baseline)
  • Create booking with 10 participants
  • Create booking with 25 participants
  • Create booking with 50 participants
  • Create booking with 100 participants (stress test)
  • Measure database queries per action
  • Measure memory usage per action
  • Profile with Symfony profiler

4.3 Comparison Testing

Compare Old vs New Flow:

  • Session DTO structure identical
  • Final booking API payload identical
  • Pricing calculations identical
  • Field handler behavior identical
  • Validation rules identical
  • Service selection logic identical
  • Room assignment logic identical
  • Insurance matching logic identical

Test Scenarios:

  • Simple booking (2 adults, no services)
  • Family booking (2 adults, 2 children, services)
  • Complex booking (mixed ages, all services, insurance)
  • Large group (50 participants)
  • Edge cases (age limits, mandatory services, mutability)

Phase 5: Code Sharing Summary

Shared Components (100%)

Services:

  • ParticipantCardDataService (new)
  • BookingService (existing)
  • BookingPriceCalculatorService (existing)
  • TravelDataService (existing)
  • RoomAssignmentService (existing)
  • ParticipantFieldHandlerRegistry (existing, extended)
  • All field handlers (existing)

Form Types:

  • BookingParticipantType (existing)
  • AddressType, BodyDimensionsType, etc. (existing)
  • All field types (existing)

Templates:

  • _participant_card.html.twig (new, shared)
  • _participant_form_standalone.html.twig (new, shared)
  • _summary.html.twig (existing, shared)

Traits:

  • ParticipantCardFlowTrait (new, shared)
  • HxTrait (existing, shared)

Controller-Specific (~20%)

Create Flow:

  • CreateStep2RefactoredController routes
  • Initial step validation
  • Navigation to step 3 on success
  • Template: create_step_2_v2_cards.html.twig

Edit Flow:

  • EditRefactoredController routes
  • Initial API data loading
  • API update on success
  • Canceled participant handling
  • Reload functionality
  • Template: edit_v2_cards.html.twig

Technical Details

Form Handling Pattern

Standard Symfony pattern - single action for both GET and POST:

public function editParticipant(int $index, Request $request): Response
{
    $bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE);
    $participant = $bookingDto->participants[$index] ?? null;

    if (null === $participant) {
        throw new \InvalidArgumentException('Invalid participant index');
    }

    // Create form with booking context passed explicitly
    $form = $this->createForm(BookingParticipantType::class, $participant, [
        'booking_context' => $bookingDto,
        'edit_mode' => false,
        'validation_groups' => ['booking_create_step_2'],
    ]);

    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        // Extract submitted data
        $submittedData = $request->request->all();
        $participantData = $submittedData['booking_participant'] ?? [];

        // Process field handlers for this participant
        $this->fieldHandlerRegistry->processFieldsForParticipant(
            $participantData,
            $bookingDto,
            $index
        );

        // Save to session
        $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);

        // Redirect back to cards
        return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_2_v2_cards'));
    }

    // Render form (GET or invalid POST)
    return $this->render('booking/_participant_form_standalone.html.twig', [
        'form' => $form,
        'participantIndex' => $index,
        'bookingDto' => $bookingDto,
        // ... summary data
    ]);
}

Card Data Structure

[
    'name' => 'Max Mustermann',  // or "Teilnehmer 1"
    'roomName' => 'Doppelzimmer',
    'price' => '450,00 €'
]

HTMX Flow Diagram

┌─────────────────────────────────────────────┐
│         CARDS VIEW (GET /participants-v2)   │
│                                             │
│  [Teilnehmer 1 | Doppelzimmer | 450,00 €]  │
│  [Teilnehmer 2 | Einzelzimmer | 520,00 €]  │
│  [Teilnehmer 3 | Doppelzimmer | 450,00 €]  │
│                                             │
│  [Zurück]                       [Weiter]    │
└─────────────────────────────────────────────┘
                │
                │ Click "Bearbeiten"
                │ hx-get="/participants-v2/0"
                │ hx-target="#main-content"
                ▼
┌─────────────────────────────────────────────┐
│  FORM VIEW (GET /participants-v2/0)         │
│                                             │
│  Teilnehmer 1                               │
│                                             │
│  [Vorname]      [Nachname]                  │
│  [Geburtsdatum] [Geschlecht]                │
│  ...                                        │
│  [Skipass ▼] ◄────────────┐                │
│  [Kurse ▼]                │                │
│                           │ Field change   │
│  [Abbrechen] [Speichern]  │ POST refresh   │
└───────────────────────────┼─────────────────┘
                           │
                           │ hx-post="/participants-v2/0/refresh"
                           │ validation_groups: false
                           │ Returns: form + sidebar OOB swap
                           ▼
┌─────────────────────────────────────────────┐
│  FORM VIEW (refreshed)                      │
│  - Form updated with new field states       │
│  - Sidebar summary updated (OOB)            │
│  - Notifications shown via toast            │
└─────────────────────────────────────────────┘
                │
                │ Click "Speichern"
                │ POST /participants-v2/0
                │ validation_groups: ['booking_create_step_2']
                ▼
         ┌──────┴──────┐
         │  Validation  │
         └──────┬──────┘
                │
        ┌───────┴────────┐
        │                │
    Valid           Invalid
        │                │
        ▼                ▼
  HX-Redirect      Return form
  to cards         with errors
        │
        ▼
┌─────────────────────────────────────────────┐
│  CARDS VIEW (all re-rendered)               │
│                                             │
│  [Max Mustermann | Doppelzimmer | 580,00 €] │ ← Updated!
│  [Teilnehmer 2   | Einzelzimmer | 520,00 €] │
│  [Teilnehmer 3   | Doppelzimmer | 450,00 €] │
└─────────────────────────────────────────────┘

Session Flow

  1. Cards View: Load DTO from session, render cards
  2. Form View: Load DTO from session, create form for participant[index]
  3. Form Refresh: Load DTO, update with field changes (no validation), save to session
  4. Form Submit: Load DTO, validate, run handlers, save to session, redirect
  5. Back to Cards: Load DTO from session (with all changes), render cards

Key: DTO is single source of truth in session, updated incrementally


Implementation Sequence

Week 1: Foundation & Create Flow

Day 1-2: Foundation

  • Create ParticipantCardDataService with tests
  • Add processFieldsForParticipant() to registry with tests
  • Create ParticipantCardFlowTrait

Day 3: Templates

  • Create _participant_card.html.twig
  • Create _participant_form_standalone.html.twig
  • Create create_step_2_v2_cards.html.twig

Day 4-5: Create Flow Controller

  • Implement CreateStep2RefactoredController
  • All three routes/actions
  • Test with small bookings (2-3 participants)
  • Test field refresh logic
  • Test validation

Week 2: Edit Flow & Testing

Day 6-7: Edit Flow

  • Implement EditRefactoredController
  • Reuse trait methods
  • Handle canceled participants
  • Create edit templates
  • Test API integration

Day 8-9: Integration Testing

  • Compare outputs (old vs new flows)
  • Test all field handlers
  • Test bulk insurance
  • Test edge cases
  • Performance test with 50+ participants

Day 10: Cleanup & Documentation

  • Code review
  • Update documentation
  • Test both flows side by side
  • Mark as ready for production consideration

Migration Strategy

Current State (Before Refactoring)

  • Old routes: /bookings/create/participants and /bookings/{id}/edit
  • Old controllers: CreateStep2Controller, EditController
  • Old templates: create_step_2.html.twig, edit.html.twig

Development State (During Refactoring)

  • Old routes remain functional and unchanged
  • New routes: /bookings/create/participants-v2 and /bookings/{id}/edit-v2
  • New controllers: CreateStep2RefactoredController, EditRefactoredController
  • New templates: create_step_2_v2_cards.html.twig, edit_v2_cards.html.twig
  • Both flows coexist - critical for testing and comparison

Transition State (Switching to New Flow)

Phase 1: Testing & Validation (During Development)

  • Deploy both flows to production (both accessible via their routes)
  • Test NEW flow manually in production environment
  • OLD routes remain the default in all navigation
  • NEW routes accessible directly for testing (e.g., via direct URL)
  • Compare bookings created through both flows
  • Verify session DTO structure is identical
  • Confirm pricing calculations match exactly

Phase 2: Navigation Switch (Single Deployment)

  • Update all navigation links to point to NEW routes
  • Update internal redirects to use NEW routes
  • OLD routes remain accessible (direct URL access works)
  • Deploy navigation changes
  • Monitor error rates and booking success rates closely
  • If issues found: Deploy rollback (revert navigation to OLD routes)

Phase 3: Stabilization (2-4 weeks)

  • Monitor NEW flow in production with all traffic
  • OLD routes still accessible but deprecated
  • No new features added to OLD flow
  • Document any issues found and resolved

Phase 4: Cleanup (After Confidence Established)

  • Remove OLD controllers (CreateStep2Controller, EditController)
  • Remove OLD templates (create_step_2.html.twig, edit.html.twig)
  • Remove OLD routes from routing configuration
  • (Optional) Remove -v2 suffix from route names and paths

Critical Transition Checklist

Before Switching Navigation:

  • All functional tests pass for NEW flow
  • Performance tests show acceptable load times
  • Comparison tests confirm identical DTO structure
  • Field handlers produce identical results
  • Pricing calculations match exactly
  • Session compatibility verified
  • HTMX refresh logic tested extensively
  • Bulk insurance functionality verified
  • All age-dependent service filtering works
  • Transportation service conditional logic works
  • Insurance auto-reassignment tested
  • Edit flow API integration verified
  • Large booking tests (50+ participants) pass

Session Compatibility:

  • Both flows use identical BookingDto structure
  • Session keys are mode-specific (MODE_CREATE, MODE_EDIT)
  • Users can switch between OLD and NEW flows without losing data
  • No session migration needed

Rollback Plan: If critical issues discovered:

  1. Immediate: Deploy navigation changes to revert links to OLD routes
  2. Investigation: Analyze logs, compare booking data, identify root cause
  3. Fix: Apply fixes to NEW flow controllers/templates
  4. Retest: Verify fixes in development environment
  5. Redeploy: Update navigation to NEW routes again

Monitoring During Transition

Key Metrics to Track:

  • Booking creation success rate (OLD vs NEW)
  • Average page load time for card grid
  • Average form load time (lazy loading)
  • Field refresh response times
  • Validation error rates
  • Session expiry rates
  • Browser console errors
  • Server error logs
  • Database query counts
  • Memory usage per request

Logging Strategy: Add structured logging to NEW flow controllers:

$this->logger->info('Refactored flow: Card view loaded', [
    'participant_count' => count($bookingDto->participants),
    'load_time_ms' => $loadTime,
    'user_id' => $user->getId(),
]);

$this->logger->info('Refactored flow: Participant form saved', [
    'participant_index' => $index,
    'had_validation_errors' => !$form->isValid(),
    'field_handler_execution_time_ms' => $handlerTime,
]);

Comparison Analysis: Since bookings are created via BPN API (not database), compare OLD vs NEW flows by:

  • Analyzing application logs for booking creation success/failure rates
  • Comparing API response times between OLD and NEW flows
  • Monitoring Symfony profiler data for both flows
  • Tracking error rates in application logs

Post-Transition Cleanup

After successful transition (2+ months with no issues):

Step 1: Remove OLD Controllers and Templates

  • Delete CreateStep2Controller.php
  • Delete EditController.php
  • Delete create_step_2.html.twig
  • Delete edit.html.twig
  • Remove OLD routes from routing configuration

Step 2: (Optional) Rename Routes

  • Rename app_booking_create_step_2_v2_cardsapp_booking_create_step_2
  • Rename app_booking_create_step_2_v2_participantapp_booking_create_step_2_participant
  • Rename app_booking_edit_v2_cardsapp_booking_edit
  • Update all navigation links and redirects
  • Test thoroughly after rename

Step 3: Verify Navigation

  • Ensure all links point to correct routes (after cleanup)
  • Check for any hardcoded OLD route references
  • Test navigation flow end-to-end

Step 4: Documentation Updates

  • Update developer documentation
  • Remove references to "refactored" or "v2" terminology
  • Update onboarding guides for new developers

Common Pitfalls to Avoid

DON'T:

  • Switch navigation to NEW flow without thorough testing in production first
  • Remove OLD controllers/routes immediately after navigation switch
  • Ignore session compatibility between flows
  • Switch without monitoring tools in place
  • Forget to update ALL navigation links (easy to miss some)

DO:

  • Test NEW flow extensively via direct URL access before navigation switch
  • Keep OLD flow accessible for weeks/months as safety net
  • Monitor metrics closely after navigation switch
  • Have rollback plan ready (revert navigation links)
  • Document all navigation link locations before switch


Success Criteria

Performance

  • Card grid loads in <1 second with 50 participants
  • Form loads instantly (lazy loading)
  • Field refresh responds in <500ms
  • Memory usage reasonable with large bookings

Functionality

  • All field handlers work identically to old flow
  • Validation rules applied correctly
  • Session DTO structure matches old flow
  • Final booking creation/update produces identical results

Code Quality

  • 80%+ code reuse between create and edit flows
  • Full test coverage for new services
  • Clean separation of concerns
  • Documented and maintainable

Notes & Considerations

Why No OOB Swaps for Cards?

When editing a participant form, the cards view is not rendered - the entire #main-content is replaced with the form. When returning to cards (via "Speichern" or "Abbrechen"), the entire cards view is re-rendered fresh from the session DTO. Therefore, no need for OOB swaps of individual card prices.

Why Lazy Loading?

With 50+ participants, rendering all forms at once (current accordion approach) causes:

  • Long initial page load time
  • High memory usage
  • Heavy DOM with hundreds of form fields Lazy loading loads only the form for the participant being edited, dramatically improving performance.

Why Separate Controllers?

Keeps old implementation intact for:

  • Comparison testing during development
  • Fallback if issues discovered
  • Easier code review (clear diff between old and new)
  • Lower risk deployment

Field Handler Registry Change

Adding processFieldsForParticipant() allows processing a single participant's data instead of looping through all participants. This is necessary because the new flow processes participants individually, not all at once.


Open Questions

  • Should we add a progress indicator showing X/Y participants completed?
  • Should we add keyboard shortcuts (e.g., Ctrl+S to save, Esc to cancel)?
  • Should we add a "Save & Next" button to quickly move through participants?
  • Should we store completion state per participant to show visual progress?

References

  • Current Implementation: src/Controller/Booking/CreateStep2Controller.php
  • Current Template: templates/booking/create_step_2.html.twig
  • Field Handlers: src/Form/Service/
  • HTMX Trait: src/Htmx/HxTrait.php