Files

18 KiB

Mobile App — Backend Plan (myep / Symfony)

Audience: sessions working in this repository (myep, Symfony 6.4). Companion: docs/mobile-app/app-plan.md — the React Native side. That document is written to be self-contained and is meant to be copied into the RN repository, whose sessions have no access to this codebase.


1. Context

myep is the booking engine behind the separate CMS marketing site (ep-reisen.de; see BookingSessionManager::DEFAULT_RETURN_URL and CmsDataProvider). It serves:

  • Individual travel bookings — system of record is BusProNet (BPN), an external XML service reached through src/BusProNet/ApiClient.php.
  • Groups / accommodation bookings — system of record is local Doctrine entities under src/Entity/Groups/.

The public UI is Twig + HTMX + Tailwind. The admin UI under /admin is out of scope for the mobile app and stays web-only.

What we are building and why

A React Native app covering the full public surface, including the booking create flow. Drivers: App Store presence/brand and a native-feeling booking experience. A PWA was explicitly ruled out. Push notifications were not named as a driver — worth remembering, since push is normally what makes native unarguable; the payoff here rests on brand and UX.

The decision that shapes everything

The sibling project is the React Native app only. The API stays in this repository.

A second backend would recreate exactly the duplication of validation and business rules this plan exists to prevent. The API is a new src/Controller/Api/App/ namespace reusing the existing services and DTOs.

Client strategy: server-driven UI (SDUI). The server emits a JSON field schema per participant/step; RN renders it generically. Business rules never leave PHP.

Why this codebase can actually support that

The rules are already headless:

  • Validation lives on DTOs, not form types. src/Form/Model/ParticipantDto.php (21 constraints), BookingDto.php, BankAccountDto.php, RegistrationDto.php carry Assert\* plus custom App\Validator\Constraints\Participant, with validation groups (booking_create, booking_edit, strict_required, applicant_address). An API endpoint can denormalize → validate($dto, groups: [...]) and get identical results.
  • Dynamic field logic is a rules engine. src/Form/Service/ holds Contract/FieldStateProviderInterface (hidden / readonly / disabled / required / static_text), Contract/FieldOptionsProviderInterface (labels + choices), ParticipantFieldHandlerRegistry (25 handlers) and 27 reusable Condition/ classes.
  • Orchestration is in services, not controllers. BookingConfigurator (652 lines), RoomAssigner, BookingPricingAssembler, BookingSummaryAssembler, VoucherValidator, BookingEditSubmitGuard, BookingEditSubmitter. Controllers are thin — Step2Controller is 147 lines, mostly session handling and redirects.

2. The four real obstacles

Everything else is mechanical. These need design work before the increments that depend on them.

Obstacle 1 — The create flow is session-bound

src/Service/BookingSessionManager.php keys the entire create flow off $request->getSession() (booking_create, booking_create_baseline_snapshot, booking_return_url). Stateless OAuth2 clients have no session.

Fix: a server-persisted draft addressed by UUID. The pattern already exists for edit — src/Entity/BookingEditDraft.php plus BookingEditDraftManager / BookingEditDraftMerger. Generalise into a BookingDraft store and put BookingStateStoreInterface in front, with two backends: session (web, unchanged) and draft (app). Do not fork the flow logic.

Obstacle 2 — FieldStateProviderInterface leaks Symfony Form

Two leaks:

  • getBookingDtoFromForm(FormInterface $form) is pure Form coupling and belongs in a form-side adapter, not the interface.
  • getFieldState() returns Symfony form options (disabled, required, attr => ['readonly' => true]), and ParticipantFieldOptionsProvider returns choices as domain objects (Service, Pickup, Insurance) plus occasional choice_loader.

Fix: a neutral FieldDescriptor value object — name, type, label, required, readonly, disabled, hidden, staticText, choices, value. Both BookingParticipantType and the new JSON serializer consume it. Conditions and handlers are untouched.

Note the hidden vs static_text distinction documented in AbstractFieldStateProvider: hidden means excluded entirely; static_text means "show the value, don't let them edit it". The app must honour both, so FieldDescriptor has to carry them separately.

Obstacle 3 — Token lifetimes and refresh-token rotation

config/packages/league_oauth2_server.yaml sets access_token_ttl: PT10M — deliberately, since the bundle's own default is PT1H. Find out why before changing it.

TTLs are global. They live under authorization_server; the bundle has no per-client TTL. Any change affects the CMS and M2M clients too. "Raise it just for the app" is not available without a custom grant or response type.

Two values are unset and defaulted, and both matter:

  • refresh_token_ttlP1M (1 month)
  • revoke_refresh_tokenstrue, so refresh-token rotation is on

Rotation is correct security, and it is also the main source of mobile auth bugs: two requests 401 concurrently, both refresh, one rotates, the second presents a revoked refresh token and the user is logged out mid-booking. At a 10-minute TTL with step 2 firing schema refreshes per field change, this will happen. The mitigation is client-side — single-flight refresh — and it is a hard requirement in app-plan.md §4.

A longer access-token TTL does not create an unrevocable window here: persist_access_token defaults to true and BearerTokenValidator checks isAccessTokenRevoked() on every request, so tokens can be killed immediately. The usual argument for a very short TTL does not apply.

Recommendation: access_token_ttl: PT1H, refresh_token_ttl: P3M set explicitly, rotation left on. Rotation issues a fresh refresh token each time, so the refresh TTL is a sliding window — active users never re-auth, only ~3 months of true inactivity forces it. M2M clients get fewer token round-trips. Extra token rows are already handled by league:oauth2-server:clear-expired-tokens, scheduled in config/packages/zenstruck_schedule.yaml.

Settle this before the RN auth layer is written, not after.

Otherwise auth is ready: enable_auth_code_grant: true with require_code_challenge_for_public_clients: true (PKCE), and enable_password_grant: false / enable_implicit_grant: false — correct for a native app.

Obstacle 4 — BPN per-user calls need the user's plaintext password

ApiClient::getBookings(), getBooking(), getPersonalData(), updatePersonalData(), updateNewsletterRegistration() all take (string $email, string $password). src/Controller/Booking/IndexController.php supplies them via $this->crypt->decrypt($user->getPassword()) — the local User stores the BPN password RSA-encrypted (src/Security/Crypt.php).

Consequences:

  • The app must never see BPN credentials. The API layer resolves them server-side from the authenticated User, exactly as the web controllers do. This is not a change, but it must be stated so nobody "simplifies" it by proxying credentials.
  • BPN-backed user data only works for users with a local User record carrying the encrypted password. Guest flows and app-only accounts cannot read BPN bookings. Confirm how registration-via-app populates this before Increment 3.
  • The OAuth2 token identifies the user; it does not carry BPN authority. Every BPN-backed endpoint needs an explicit ownership check, not just a valid token.

3. Increments

Ordered local-data-first, BPN-last, so each stage ships something usable and the riskiest integration lands after the SDUI machinery is proven.

Increment 0 — Foundations

No user-visible feature. Backend side:

  • Register an OAuth2 client for the app (authorization_code + PKCE, native redirect URI).
  • Resolve Obstacle 3 (TTL).
  • Resolve device→DDEV access: .ddev/config.yaml has ngrok_args; ddev share is the path. Redirect URIs must be registered against whatever host the app actually calls.
  • Create docs/app-api/ with the contract skeleton and the fixtures directory.

Increment 1 — Accommodation & group offers (pilot)

Local Doctrine data, UUID-addressed, and a write endpoint already exists. Zero BPN risk.

  • Reuse GET /api/accommodation-bookings/{uuid} and POST /api/accommodation-bookings/{uuid}/accept (src/Controller/Api/AccommodationBookingController.php).
  • Add a list endpoint scoped to the authenticated user.
  • Mirrors src/Controller/Groups/Offer/IndexController.php; reuse AccommodationBookingService, AccommodationBookingBreakdownCalculator, AccommodationBookingApiResponse.
  • Validates: auth, serialization conventions, RN navigation, the fixture pipeline.

Increment 2 — Account, personal data, newsletter, registration

  • Mirror src/Controller/Account/, RegistrationController, ResetPasswordController, src/Controller/Newsletter/.
  • Reuse RegistrationDto, the PersonalDataType DTO, NewsletterManager, ProfileCompletenessChecker, and existing /api/newsletter-subscriptions, /api/userinfo, /api/crm-attributes.
  • First real SDUI use, on a small low-risk form, before betting the booking flow on it.
  • Registration here is what makes Obstacle 4 tractable — verify the encrypted-password path is populated for app-registered users.

Increment 3 — Booking read

  • GET /api/app/bookings and /api/app/bookings/{id}, mirroring src/Controller/Booking/IndexController.php (note its TravelLoader::patchBookings() enrichment step).
  • Documents/invoice, mirroring src/Controller/Booking/DownloadController.php.
  • Reuse BookingSummaryAssembler, BookingPricingAssembler, BookingEditDataLoader.
  • BPN reads only, no writes — the safe way to shake out BusProNet\Model normalization (Travel, Booking, Service, Room, Pickup, Insurance), which every later increment depends on.

Increment 4 — FieldDescriptor refactor

Pure internal refactor. No new endpoints. Covered by the existing 155 test files.

  • Introduce FieldDescriptor; split getBookingDtoFromForm out of FieldStateProviderInterface.
  • Adapt BookingParticipantType::mergeFieldState() / addDynamicFields() to consume it.
  • Add ParticipantFieldSchemaSerializer producing the JSON schema, including the dependsOn map derived from FieldConditionInterface::getDependentFields() and ParticipantFieldHandlerInterface::getDependencies().
  • Ship criterion: web behaviour byte-identical, Cypress specs green, no test file changed. A required test change means a behaviour regression.

Increment 5 — Booking edit

Before create, because drafts already exist here.

  • Endpoints over BookingEditDraftManager, BookingEditPreFlightChecker, BookingEditSubmitGuard, BookingEditSubmitter, BookingChangeTracker.
  • Participant schema endpoint from Increment 4.
  • Honour BookingMutabilityDto and BPN's aenderungmoeglich mutability rules — these already drive static_text, so SDUI inherits them.
  • Caution: the participant[0]/applicant block is on hold pending BPN vendor confirmation about the shared customer id. Do not try to resolve it here.

Increment 6 — Booking create

The full flow, last. Web routes being mirrored:

Step Web route Controller
init /bookings/create, /bookings/create/init Create/IndexController.php
1 — rooms /bookings/create/rooms (+ /refresh) Create/Step1Controller.php
2 — participants /bookings/create/participants (+ /{index}, /{index}/refresh) Create/Step2Controller.php, Step2ParticipantController.php
3 — payment /bookings/create/payment (+ /refresh) Create/Step3Controller.php
4 — confirmation /bookings/create/confirmation Create/Step4Controller.php
success /bookings/create/success Create/SuccessController.php
  • Generalise draft storage behind BookingStateStoreInterface (Obstacle 1).
  • Reuse BookingConfigurator (startFreshBooking, preselectDefaultServices, applyCreateBookingStatusRules, updateBookingStatusFromRoomSelection), RoomAssigner, ParticipantDataPrefiller, TravelDataProvider, VoucherValidator, FamilyInsuranceAvailabilityChecker.
  • Submit via BusProNet\ApiClient::createBooking() / createBookingInquiry() with BookingPayloadBuilder, exactly as Step4Controller does.
  • Every HTMX refresh route gets a JSON sibling returning recomputed schema + pricing.

Increment 7 — Discovery (scoping decision, not yet planned)

Travel/hotel browsing lives in the CMS, not here — this repo only proxies it via CmsDataProvider and /api/hotels, /api/products, /api/travels. If the app is to be a standalone entry point rather than a deep-link target, that is a second integration project and should be scoped separately.


4. Files

New (representative):

  • src/Controller/Api/App/ — one controller per resource, mirroring the public tree
  • src/Form/Service/Model/FieldDescriptor.php
  • src/Form/Service/ParticipantFieldSchemaSerializer.php
  • src/Service/BookingStateStoreInterface.php + draft-backed implementation
  • src/Entity/BookingDraft.php (generalised from BookingEditDraft) + migration
  • Normalizers for BusProNet\Model\* value objects
  • tests/Controller/Api/App/

Modified:

  • src/Form/Service/Contract/FieldStateProviderInterface.php — drop the Form-coupled method
  • src/Form/Service/Abstract/AbstractFieldStateProvider.php, CreateFieldStateProvider.php, EditFieldStateProvider.php
  • src/Form/BookingParticipantType.php — consume FieldDescriptor
  • src/Service/BookingSessionManager.php — implement the store interface
  • config/packages/league_oauth2_server.yaml — TTL, app client, scopes
  • docs/api-consumer-guide.md / docs/app-api/ — the contract

Unchanged by design: every src/Form/Service/Condition/*, every src/Form/Service/Participant*FieldHandler.php, every src/Validator/Constraints/*, and all pricing services. If an increment starts wanting to edit these, the design has drifted — stop and reconsider.


5. Working across two repos and two sessions

The RN work happens in a separate repo and a separate sandboxed session. The risk that creates is contract drift: the app session guessing at response shapes, or worse, re-deriving business rules in TypeScript because the JSON was ambiguous. That is the original duplication problem arriving through a side door.

The contract is an artifact in this repo

docs/api-consumer-guide.md already opens with "It is self-contained: no access to this codebase is required to use it." That is the format. Extend it — or split out docs/app-api/guide.md once it outgrows one file — in the same commit as the endpoint, every time.

Rule: the RN session never reads this PHP repo. Its ground truth is the guide plus fixtures. If it has to ask "what does the server send here?", the guide has a gap — fix the guide, not the app.

Fixtures decouple the sessions

Parity tests in tests/Controller/Api/App/ write their JSON responses to docs/app-api/fixtures/*.json (mirroring the existing tests/Resources/*.xml convention). Commit them. The RN session mocks against those files, so app work never waits on a deployed endpoint — and because they are test output, they cannot silently go stale.

Per-increment rhythm

  1. Backend session (here): endpoint + parity test + fixtures + guide update.
  2. Handoff: a short brief — endpoints now live, fixtures to mock, what changed in the guide since last time.
  3. App session (RN repo): build against fixtures first, then against DDEV.
  4. Joint verification: run the increment end-to-end against a real backend.

Do not run both sessions on the same increment concurrently. Overlap by one stage: while the app session builds increment N, start N+1 here.


6. Verification

Per increment:

  1. vendor/bin/phpunit — 155 existing test files, notably tests/Form/, tests/Service/, tests/BusProNet/. Increment 4 must pass with no test changes.
  2. vendor/bin/phpstan analyse and vendor/bin/php-cs-fixer fix --dry-run.
  3. npx cypress run — existing specs cover the web booking flow and guard against SDUI refactors leaking into Twig rendering.
  4. Parity tests under tests/Controller/Api/App/, asserting the JSON schema matches what BookingParticipantType builds for the same BookingDto. This is the single most valuable artifact in the project — it mechanically proves web and app share one rule set.
  5. Manual: extend the existing api.http / http-client.env.json collection per increment.
  6. End-to-end: run one booking through the RN client against a DDEV backend and confirm the BPN payload is identical to a web-flow booking with the same inputs — compare via src/Controller/Admin/Log/XmlDumpController.php.

7. Open questions before Increment 6

  • App-client access token TTL (Obstacle 3).
  • How app registration populates the encrypted BPN password (Obstacle 4), and whether guest/unauthenticated create is supported in the app as it is on the web via /bookings/create/init.
  • Draft retention and expiry policy.
  • Whether the app deep-links from the CMS site or must own discovery (Increment 7).