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.phpcarryAssert\*plus customApp\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/holdsContract/FieldStateProviderInterface(hidden / readonly / disabled / required / static_text),Contract/FieldOptionsProviderInterface(labels + choices),ParticipantFieldHandlerRegistry(25 handlers) and 27 reusableCondition/classes. - Orchestration is in services, not controllers.
BookingConfigurator(652 lines),RoomAssigner,BookingPricingAssembler,BookingSummaryAssembler,VoucherValidator,BookingEditSubmitGuard,BookingEditSubmitter. Controllers are thin —Step2Controlleris 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]), andParticipantFieldOptionsProviderreturnschoicesas domain objects (Service,Pickup,Insurance) plus occasionalchoice_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_ttl→P1M(1 month)revoke_refresh_tokens→true, 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
Userrecord 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.yamlhasngrok_args;ddev shareis 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}andPOST /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; reuseAccommodationBookingService,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, thePersonalDataTypeDTO,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/bookingsand/api/app/bookings/{id}, mirroringsrc/Controller/Booking/IndexController.php(note itsTravelLoader::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\Modelnormalization (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; splitgetBookingDtoFromFormout ofFieldStateProviderInterface. - Adapt
BookingParticipantType::mergeFieldState()/addDynamicFields()to consume it. - Add
ParticipantFieldSchemaSerializerproducing the JSON schema, including thedependsOnmap derived fromFieldConditionInterface::getDependentFields()andParticipantFieldHandlerInterface::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
BookingMutabilityDtoand BPN'saenderungmoeglichmutability rules — these already drivestatic_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()withBookingPayloadBuilder, exactly asStep4Controllerdoes. - 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 treesrc/Form/Service/Model/FieldDescriptor.phpsrc/Form/Service/ParticipantFieldSchemaSerializer.phpsrc/Service/BookingStateStoreInterface.php+ draft-backed implementationsrc/Entity/BookingDraft.php(generalised fromBookingEditDraft) + migration- Normalizers for
BusProNet\Model\*value objects tests/Controller/Api/App/
Modified:
src/Form/Service/Contract/FieldStateProviderInterface.php— drop the Form-coupled methodsrc/Form/Service/Abstract/AbstractFieldStateProvider.php,CreateFieldStateProvider.php,EditFieldStateProvider.phpsrc/Form/BookingParticipantType.php— consumeFieldDescriptorsrc/Service/BookingSessionManager.php— implement the store interfaceconfig/packages/league_oauth2_server.yaml— TTL, app client, scopesdocs/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
- Backend session (here): endpoint + parity test + fixtures + guide update.
- Handoff: a short brief — endpoints now live, fixtures to mock, what changed in the guide since last time.
- App session (RN repo): build against fixtures first, then against DDEV.
- 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:
vendor/bin/phpunit— 155 existing test files, notablytests/Form/,tests/Service/,tests/BusProNet/. Increment 4 must pass with no test changes.vendor/bin/phpstan analyseandvendor/bin/php-cs-fixer fix --dry-run.npx cypress run— existing specs cover the web booking flow and guard against SDUI refactors leaking into Twig rendering.- Parity tests under
tests/Controller/Api/App/, asserting the JSON schema matches whatBookingParticipantTypebuilds for the sameBookingDto. This is the single most valuable artifact in the project — it mechanically proves web and app share one rule set. - Manual: extend the existing
api.http/http-client.env.jsoncollection per increment. - 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).