17 KiB
MyEP Next Booking - Project Overview
Symfony 6.4 travel booking application integrating with Bus Pro Net (BPN) XML API.
Core Architecture
Multi-Step Booking Flow
- Step 1: Room selection and dates (
Create\Step1Controller) - Step 2: Participant details with card-based UI (
Create\Step2Controller) - Step 3: Payment method selection (
Create\Step3Controller) - Step 4: Final confirmation and submission to BPN API (
Create\Step4Controller) - Edit Flow: Similar card-based UI for existing bookings (
Edit\IndexController)
Card-Based UI Pattern (Production)
- Overview: Grid of participant cards with lazy-loaded individual forms
- Performance: Handles 50+ participants efficiently via HTMX
- Controllers:
Create\Step2Controller- create flow with validation before step 3Edit\IndexController- edit flow with validation before API submission
- Shared Logic:
ParticipantCardFlowTrait- Card rendering and form handlingParticipantValidationTrait- Validation error extraction for card indicators
- Validation Pattern:
- Both controllers use validation-only forms (
BookingCreateStep2Type,BookingEditType) - Standard Symfony form flow:
handleRequest()→isSubmitted()→isValid() - On invalid: Extract error indices, display error banner, highlight cards, disable submit button
- On valid: Proceed to next step (create) or submit to API (edit)
- Both controllers use validation-only forms (
Key Architectural Layers
BusProNet Integration (src/BusProNet/)
ApiClient- XML API communicationXmlParser/- Response parsers (travels, hotels, bookings)XmlLoader/- Data loaders with cachingDataProcessor/- Transform API data to DTOs
Form System (src/Form/)
- DTOs:
BookingCreateDto,ParticipantDto(session-stored) - Field Handlers: 15+ specialized handlers in
src/Form/Service/- Registered via service tags with dependency resolution
- Process in dependency order via
ParticipantFieldHandlerRegistry
- Conditional Fields: Universal condition system (
FieldConditionInterface)- Age-based, field-dependent, service-specific conditions
- Applied via
CreateFieldStateProvider/EditFieldStateProvider
- HTMX Integration: Real-time updates for dynamic fields
Service Layer (src/Service/)
BookingService- Core booking workflowBookingPriceCalculatorService- Real-time pricingBookingFingerprintService- Dirty state detection for edit modeTravelDataService- API integration and cachingParticipantCardDataService- Card display dataInsuranceMatchingService- Insurance eligibility and auto-reassignmentRoomAssignmentService- Automatic room assignment
Critical Patterns
Field Handler Pattern
// Handlers process in dependency order (topological sort)
// Example: insurance handler depends on ALL price-affecting fields
$this->fieldHandlerRegistry->processFieldsForParticipant(
$participantData,
$bookingDto,
$index
);
Important: Field handlers use "sync pattern" - only sync fields present in original submission to avoid validation errors.
HTMX Block-Based Rendering
// All HTMX swaps target #main-content with innerHTML
// OOB swaps for sidebar: #booking-summary
return $this->htmxOobResponse(
'booking/_participant_form.html.twig',
['participant_form', 'booking_summary'],
$templateData
);
Service Enrichment Pattern
Services from BPN API may lack complete data (especially prices). Always enrich from travel data:
// BookingDataProcessor::enrichParticipantServicesFromTravel()
// Looks up each service in travel data and replaces with full version
Notification System
Field handlers generate notifications (auto-changes) → collected by controller → sent via HX-Trigger → displayed as toasts.
Dirty State Detection (Edit Mode)
Fingerprint-based change detection to warn users about unsaved modifications:
- BookingFingerprintService generates SHA-256 hash of all mutable booking data
- Original fingerprint stored in
BookingDto::$originalFingerprinton API load isDirty()compares current state with original to detect changes- Yellow warning banner displays when changes detected
- Update button conditionally shown only when dirty
- Fingerprint persists in session, survives page refreshes
- Resets on submission, "Änderungen verwerfen", or "Zurück" actions
Unavailable Services Handling:
- Services with
available <= 0included in edit mode (not filtered out) ParticipantFieldOptionsProvider::shouldMakeServiceReadonly()implements intelligent readonly logic:- Create mode: Uses standard availability calculator (filters out unavailable services)
- Edit mode: Services participants already have remain editable even if now fully booked
- Edit mode: Unavailable services participant doesn't have are marked readonly
- Prevents fingerprint false positives when services become fully booked during editing session
- Ensures service IDs remain consistent in form submissions for accurate dirty detection
Key Service Dependencies
Field Handler Execution Order
Critical for correct pricing and auto-reassignment:
- Age-dependent fields (dateOfBirth)
- Price-affecting services (skiPass, rentals, courses, board, transportation, pickup, parking)
- Insurance handler LAST (depends on all price-affecting fields)
- Bulk insurance handler (applicant only)
Insurance System
- 3-pass parsing: Referenced IDs → Individual insurances → Packages with family detection
- Auto-reassignment: Maintains insurance type when price tier changes
- Age constraints: Absolute age (at travel date) vs birth year
- Hydration:
TravelDataService::hydrateInsurancePackageRelationships()rebuilds package relationships after cache deserialization - ID Type: Insurance IDs are strings (not integers) - ensure all test fixtures use string IDs
- Mutability: Insurances are always readonly in edit mode (API limitation) - see
InsuranceMutabilityCondition
Transportation Services
- Unified pickup field: Single field for both directions (BPN API limitation)
- Conditional visibility: Pickup vs parking fields mutually exclusive
- Direction mapping:
DirectionMappertranslates API ↔ internal codes
Important Field Dependencies
Ski Pass → Rentals → Insurance → Body Dimensions
- Rentals filtered by ski pass duration (exact date matching)
- Rental insurance only shown when rentals selected
- Body dimensions only shown when rentals selected
- All cleared automatically when dependencies removed
Date of Birth → Age-Dependent Services
- Courses, additional services, board, insurance hidden until DOB provided
- Age evaluated at travel start date, not current date
- Dual constraint types: absolute_age, birth_year, mixed
Bulk Insurance Booking
- Applicant enables bulk → applies to all participants
BulkInsuranceBookingConditionhides dependent participant insurance fields- Uses
InsuranceMatchingService::batchAssignInsuranceToParticipants()for price tier matching
Data Flow
Create Flow
- Load/create DTO from session
- Enrich with fresh API availability data
- Auto-assign rooms, preselect mandatory services
- Render cards → user edits participant → field handlers process → save to session
- Validation check before step 3
- Final submission to BPN API
Edit Flow
- Load booking from BPN API on first visit
- Generate fingerprint of initial state for dirty detection
- Store in session with
MODE_EDIT - Apply mutability constraints via
EditFieldStateProvider - Same card-based UI as create flow
- Handle canceled participants (status 'S')
- Display warning banner when unsaved changes detected
- Validation on submission: Form wraps cards, validates all participants before API call
- Submit changes back to BPN API (only if validation passes)
- Staleness warnings after 5 minutes
- Session cleanup: "Zurück" button clears session via
app_booking_edit_cancelaction
Common Development Tasks
Adding a New Field Handler
- Create handler class extending
AbstractParticipantFieldHandler - Implement
shouldProcess(),process(),getDependencies() - Register in
services.yamlwithparticipant.field_handlertag - Add field options to
ParticipantFieldOptionsProvider - Add conditional logic to
CreateFieldStateProviderif needed - Update template with HTMX refresh triggers
Adding a New Conditional Field
- Create condition class implementing
FieldConditionInterface - Register in
CreateFieldStateProvider::registerFieldStateConditions() - Use composite conditions for complex logic (AND/OR/NOT)
Debugging Field Handler Issues
- Check execution order in
ParticipantFieldHandlerRegistry(topological sort) - Verify
shouldProcess()logic for mode awareness - Ensure dependencies declared correctly
- Check sync pattern: only sync fields in original submission
Writing Tests
- Insurance IDs: Always use strings, not integers (e.g.,
'100'not100) - Room properties: Use
$labelproperty, not$name - Participant names: Index 0 expects "Anmelder:in", others expect "Teilnehmer:in N" (1-based)
- Mock dependencies: Ensure all constructor dependencies have mocks (especially new ones like
InsuranceLoader,InsuranceTypeFilterService) - Insurance mutability: In edit mode, insurances are always readonly (API limitation)
File Locations
Key Design Patterns
Service Availability in Edit Mode: Edit mode requires special handling of unavailable services to prevent fingerprint false positives:
// ParticipantFieldOptionsProvider - intelligent availability filtering
$bookingDto->travel->getAdditionalServicesBySubTypes(
Constants::TOKEN_COURSES,
BookingDto::MODE_CREATE === $bookingDto->getMode() // Only filter in create mode
)
// shouldMakeServiceReadonly() - context-aware readonly logic
// In edit mode: service readonly ONLY if unavailable AND participant doesn't have it
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool
{
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
}
// Edit mode: allow keeping services participant already has
if (null !== $service->available && $service->available > 0) {
return false;
}
$participantHasService = match ($fieldName) {
'courses' => $this->hasServiceById($participant->courses, $service->id),
'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id),
// ... other field types
};
return !$participantHasService; // Readonly only if participant doesn't have it
}
Session Lifecycle Management: Edit mode session requires proper cleanup to prevent dirty state persistence:
- Entry:
IndexController::loadFormData()initializes from API with original fingerprint - Exit (save):
IndexController::index()clears session on successful API update - Exit (discard):
IndexController::reloadFromApi()clears session and reloads from API - Exit (cancel):
IndexController::cancelEdit()clears session when user clicks "Zurück" - Validation: Dirty state persists across page refreshes until explicit action taken
Controllers
Create Namespace (src/Controller/Booking/Create/):
IndexController.php- Booking session initialization and error handlingStep1Controller.php- Room selection and datesStep2Controller.php- Participant details with card-based UIStep3Controller.php- Payment method selectionStep4Controller.php- Final confirmation and API submissionSuccessController.php- Success page after booking completion
Edit Namespace (src/Controller/Booking/Edit/):
IndexController.php- Edit flow with card-based UI, validation, and session managementindex()- Main edit view with dirty state detectioneditParticipant()- Individual participant form editingrefreshParticipantForm()- HTMX refresh without validationreloadFromApi()- Discard changes and reload from APIcancelEdit()- Clean session exit to bookings list
Root Booking Namespace (src/Controller/Booking/):
IndexController.php- Bookings listDownloadController.php- Booking document downloads
Shared Traits (src/Controller/Booking/Traits/):
ParticipantCardFlowTrait- Card rendering, form creation, summary calculationParticipantValidationTrait- Validation error extraction for card indicatorsBookingCreateTrait- Create flow helpersBookingDataTrait- API data fetchingBookingExceptionHandlerTrait- Error handling
Templates
Create Flow:
templates/booking/create/step_1.html.twig- Room selectiontemplates/booking/create/step_2.html.twig- Participant cardstemplates/booking/create/step_3.html.twig- Payment methodtemplates/booking/create/step_4.html.twig- Confirmationtemplates/booking/create/success.html.twig- Success pagetemplates/booking/create/error.html.twig- Error page
Edit Flow:
templates/booking/edit/index.html.twig- Edit with participant cards
Shared Components:
templates/booking/_participant_card.html.twig- Individual participant cardtemplates/booking/_participant_form.html.twig- Participant edit formtemplates/booking/_summary.html.twig- Pricing summary sidebar
Services
src/Service/BookingService.php- Core workflow and session managementsrc/Service/BookingFingerprintService.php- Dirty state detection via SHA-256 fingerprintingsrc/Service/ParticipantCardDataService.php- Card data generationsrc/Form/Service/ParticipantFieldHandlerRegistry.php- Handler orchestrationsrc/Form/Service/ParticipantFieldOptionsProvider.php- Field configuration with mode-aware availabilitysrc/Form/Service/CreateFieldStateProvider.php- Conditional field statessrc/Form/Service/EditFieldStateProvider.php- Edit mode mutability constraints
Field Handlers
src/Form/Service/Participant*FieldHandler.php(15+ handlers)- Transportation, pickup, parking, ski pass, rentals, insurance, bulk insurance, etc.
Testing
./vendor/bin/phpunit # All tests (182 tests, 465 assertions)
./vendor/bin/phpunit tests/Service/ # Service layer
./vendor/bin/phpunit tests/BusProNet/ # API integration
./vendor/bin/phpunit tests/Form/ # Form processing and field handlers
/opt/homebrew/bin/php-cs-fixer fix --rules=@Symfony # Code style (Symfony ruleset)
Test Coverage Areas:
- BusProNet data loaders and processors
- XML parsers (travels, hotels, bookings, insurances)
- Form DTOs and field handlers
- Service layer (pricing, insurance matching, room assignment)
- Conditional field system
- Utility classes
Development Environment
ddev start # Start DDEV
ddev composer install # Install dependencies
ddev exec bin/console cache:clear # Clear cache
ddev logs # Read PHP error logs
ddev exec "php -r 'opcache_reset()';" # Clear opcache after code changes
Important Notes
- Room prices are per person
- Room model uses
$labelproperty (not$name) - ensure test fixtures use correct property - Zero prices display without suffix (e.g., "Vollpension" not "Vollpension (€0,00)")
- All services sorted by price (cheapest first) via
SortByPriceTrait - Field sync pattern critical: Only sync fields in original submission to avoid "extra fields" errors
- Insurance handler requires mode awareness: Skips processing in edit mode (API doesn't return insurance data)
- Insurance IDs are strings: All insurance IDs must be strings, not integers (type safety)
- Clear opcache after code changes affecting hydration or serialization
- HTMX targeting consistency: All swaps target
#main-contentwithinnerHTML, sidebar via OOB swap - Validation pattern: Both create and edit flows use validation-only forms that wrap card UI for standard Symfony form handling
- Card error indicators:
ParticipantValidationTrait::extractParticipantErrorIndices()parses form errors to highlight invalid participant cards - Edit mode service availability: Services with
available <= 0remain visible and editable for participants who already have them (prevents fingerprint false positives) - Session cleanup on exit: All exit paths from edit mode (save, discard, cancel) properly clear session to reset dirty state
- Participant naming convention: Index 0 is "Anmelder:in", others are "Teilnehmer:in N" (1-based, not 0-based)
References
- Project conventions:
../CLAUDE.md(root level) - User preferences:
~/.claude/CLAUDE.md - Documentation index:
README.md(this directory)