Files
myep/REFACTORING_PARTICIPANT_CARDS.md
T

45 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

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

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 Create ParticipantFormDto

File: src/Form/Model/ParticipantFormDto.php

Purpose: Dedicated DTO for editing a single participant with full booking context

Why This Is Needed: The existing BookingParticipantType uses getBookingDtoFromForm() which traverses up the form tree to find a parent form with BookingDto as its data. This DTO provides that context while maintaining clean separation between the booking flow state and individual participant editing.

Architecture Benefits:

  • Clean separation: BookingDto = full booking flow state, ParticipantFormDto = single participant editing context
  • No pollution of BookingDto with editing-specific properties
  • Type-safe and explicit relationships
  • Participant has full booking context for conditional logic
  • Field state providers work unchanged

Implementation:

/**
 * DTO for editing a single participant within a booking context.
 *
 * This DTO encapsulates the participant being edited along with the full
 * booking context needed for field state evaluation, pricing calculations,
 * and conditional logic.
 */
class ParticipantFormDto
{
    public function __construct(
        public BookingDto $bookingContext,
        public ParticipantDto $participant,
    ) {}
}

Key Points:

  • bookingContext provides full booking state for field conditions
  • participant is the participant being edited
  • Participant index available via $participant->index (no separate property needed)

Todos:

  • Create DTO class in src/Form/Model/
  • Add constructor with type hints
  • Write unit tests for DTO instantiation
  • Test with field state providers

1.4 Create ParticipantFormType

File: src/Form/ParticipantFormType.php

Purpose: Form type for editing a single participant with automatic field handler processing

Implementation:

class ParticipantFormType extends AbstractType
{
    public function __construct(
        private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry,
    ) {}

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('participant', BookingParticipantType::class, [
                'edit_mode' => $options['edit_mode'],
            ])
            ->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']);
    }

    /**
     * Process field handlers before form binding and validation.
     *
     * This follows the same pattern as BookingCreateStep2Type - field handlers
     * are called during PRE_SUBMIT to clean and validate data before binding.
     */
    public function onPreSubmit(FormEvent $event): void
    {
        $form = $event->getForm();
        $submittedData = $event->getData();

        /** @var ParticipantFormDto $participantFormDto */
        $participantFormDto = $form->getData();
        $bookingDto = $participantFormDto->bookingContext;
        $participantIndex = $participantFormDto->participant->index;

        // Extract participant data from submitted form
        $participantSubmittedData = $submittedData['participant'] ?? [];

        // Process field handlers for this single participant
        $this->participantFieldHandlerRegistry->processFieldsForParticipant(
            $participantSubmittedData,
            $bookingDto,
            $participantIndex
        );

        // Note: No need to sync data back - the DTO is updated by reference
        // and handleRequest() will bind the updated values
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => ParticipantFormDto::class,
            'edit_mode' => false,
        ]);

        $resolver->setAllowedTypes('edit_mode', 'bool');
    }
}

Key Points:

  • Field handlers called automatically in PRE_SUBMIT event (matches existing pattern in BookingCreateStep2Type)
  • Controller doesn't need to call field handlers manually
  • Uses processFieldsForParticipant() to process only the current participant
  • Root form data is ParticipantFormDto which provides BookingDto context via bookingContext property

Todos:

  • Create form type with DI for field handler registry
  • Implement buildForm with participant field
  • Implement onPreSubmit event listener
  • Add proper PHPDoc
  • Test field handler execution
  • Test validation behavior

1.5 Update FormTraversalTrait

File: src/Form/Service/Trait/FormTraversalTrait.php

Purpose: Support both BookingDto and ParticipantFormDto as root form data

Modification:

trait FormTraversalTrait
{
    /**
     * Gets the BookingDto from the root of the form tree.
     *
     * Supports both direct BookingDto (used in current flows) and
     * ParticipantFormDto wrapper (used in card-based flows).
     *
     * @param FormInterface $form The form to start traversing from
     *
     * @return BookingDto|null The booking DTO or null if not found
     */
    public function getBookingDtoFromForm(FormInterface $form): ?BookingDto
    {
        // Traverse up the form tree to get the root form's data
        $rootForm = $form;
        while ($rootForm->getParent()) {
            $rootForm = $rootForm->getParent();
        }

        $data = $rootForm->getData();

        // Direct BookingDto (used in current flows)
        if ($data instanceof BookingDto) {
            return $data;
        }

        // ParticipantFormDto wrapper (used in card-based flows)
        if ($data instanceof ParticipantFormDto) {
            return $data->bookingContext;
        }

        return null;
    }
}

Why This Works:

  • Existing flows use BookingDto directly → no changes needed
  • New card flows use ParticipantFormDto → extracts bookingContext
  • Field state providers work with both approaches seamlessly
  • BookingParticipantType remains completely unchanged

Todos:

  • Add ParticipantFormDto support to trait
  • Add use statement for ParticipantFormDto
  • Test with existing flows (should work unchanged)
  • Test with new card flows
  • Verify field state providers work correctly

1.6 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
  4. Set HTMX attributes for refresh endpoint
  5. $form->handleRequest($request)
  6. If submitted and valid:
    • Extract participant data from submitted form
    • Process field handlers via processFieldsForParticipant()
    • Save BookingDto to session
    • HTMX redirect to cards view
  7. Calculate summary data for sidebar
  8. 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 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);

    $form = $this->createParticipantForm($bookingDto, $index, [
        '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