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:
ParticipantFormTypewrapper - not needed - ✅ Simplified: Pass
BookingDtovia form options (booking_context) - ✅ Simplified: Reuse
BookingParticipantTypedirectly - ✅ 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:
- Create mode: Missing data = user didn't select anything (clear it)
- 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
$modeparameter toParticipantFieldHandlerInterface::shouldProcess() - ✅ Updated
AbstractParticipantFieldHandlerwith mode parameter - ✅ Added helper methods:
isEditMode(),isCreateMode()for future use - ✅ Updated
ParticipantFieldHandlerRegistryto pass$bookingDto->modeto 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
ParticipantFormDtowrapper - ✅ No
ParticipantFormTypewrapper - ✅ Reuse existing
BookingParticipantTypedirectly - ✅ 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->lastNameor fallback to "Teilnehmer {index+1}" - Room: Look up from
$bookingDto->travel->getRoomById($participant->assignedRoomId) - Price: Use
BookingPriceCalculatorService::calculateAllParticipantIndividualPrices(), format asnumber_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
ParticipantFieldHandlerRegistryto constructor (DI) - Add
booking_contextoption toconfigureOptions() - Update
buildForm()to capturebooking_contextand add PRE_SUBMIT listener - Add
processFieldHandlers()private method to call registry - Update
onPreSetData()to accept and use$bookingContextparameter - Update
onPreSubmit()to accept and use$bookingContextparameter - 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()usingParticipantCardDataService - 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:
- Load BookingDto from session (reuse
getOrCreateBookingCreateDto()) - Enrich with fresh availability data
- Validate step access
- Ensure correct number of participants
- Auto-assign rooms if needed
- Preselect mandatory services
- Save BookingDto to session
- Generate all cards data via
ParticipantCardDataService - Calculate summary data
- Render
create_step_2_v2_cards.html.twig
Action: editParticipant(int $index, Request $request): Response
Implementation steps:
- Load BookingDto from session
- Validate participant index
- Create form for
$bookingDto->participants[$index]usingBookingParticipantTypewithbooking_contextoption:$form = $this->createForm(BookingParticipantType::class, $participant, [ 'booking_context' => $bookingDto, 'edit_mode' => false, 'validation_groups' => ['booking_create_step_2'], ]); $form->handleRequest($request)- Note: Field handlers are called automatically by the form in PRE_SUBMIT
- If submitted and valid:
- Save BookingDto to session
- HTMX redirect to cards view
- Calculate summary data for sidebar
- Render
_participant_form_standalone.html.twigwith form and summary
Action: refreshParticipantForm(int $index, Request $request): Response
Implementation steps:
- Load BookingDto from session
- Enrich with fresh availability data
- Create form with
booking_contextoption andvalidation_groups: false:$form = $this->createForm(BookingParticipantType::class, $participant, [ 'booking_context' => $bookingDto, 'edit_mode' => false, 'validation_groups' => false, ]); $form->handleRequest($request)- Extract participant data from submitted form
- Process field handlers via
processFieldsForParticipant() - Preselect mandatory services
- Save BookingDto to session
- Collect notifications from participant DTO
- Calculate summary data
- Render form + sidebar using
htmxOobResponse()with blocks:participant_form(target)booking_summary(OOB swap)
- 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-contentarea
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:
CreateStep2RefactoredControllerroutes- Initial step validation
- Navigation to step 3 on success
- Template:
create_step_2_v2_cards.html.twig
Edit Flow:
EditRefactoredControllerroutes- 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
- Cards View: Load DTO from session, render cards
- Form View: Load DTO from session, create form for participant[index]
- Form Refresh: Load DTO, update with field changes (no validation), save to session
- Form Submit: Load DTO, validate, run handlers, save to session, redirect
- 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
ParticipantCardDataServicewith 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/participantsand/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-v2and/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
-v2suffix 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
BookingDtostructure - ✅ 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:
- Immediate: Deploy navigation changes to revert links to OLD routes
- Investigation: Analyze logs, compare booking data, identify root cause
- Fix: Apply fixes to NEW flow controllers/templates
- Retest: Verify fixes in development environment
- 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_cards→app_booking_create_step_2 - Rename
app_booking_create_step_2_v2_participant→app_booking_create_step_2_participant - Rename
app_booking_edit_v2_cards→app_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