ADR-001: Global Architecture and Separation of Concerns
Status: Accepted
Date: 2026-02-19
Decision Makers: Lead Developer
Context: Bike Trip Planner MVP — Local-first bikepacking trip generator
Context and Problem Statement
Bike Trip Planner aims to be a "local-first" application that generates bikepacking itineraries without relying on a persistent cloud database (e.g., MySQL or PostgreSQL) for user accounts or trip history. The application state is entirely maintained in the user's browser and serialized to/from local JSON files.
Despite having no database, the application requires heavy processing:
- Parsing and manipulating large GPX files or Komoot URLs.
- Executing a "Pacing Engine" (a mathematical fatigue calculation algorithm) to chunk routes.
- Querying external geographical APIs (OpenStreetMap Overpass) and weather APIs securely.
The core architectural problem is defining the strict boundaries between the client (browser) and the server to ensure high performance, secure API key management, and optimal maintainability. Furthermore, the project demands a high level of code quality (frontend and backend) and must adhere to industry standards for API design to ensure flawless communication between the frontend and the stateless backend.
Architectural Requirements
| Requirement | Description |
|---|---|
| State Management | Must hold the entire trip state locally (browser) and allow JSON export/import. |
| Security | Must securely query OpenWeather and OSM Overpass without exposing API keys in the client. |
| Standardization | The API must expose a strict, self-documenting contract (OpenAPI) to prevent data drift. |
| Code Quality | Strict static analysis and automated formatting must be enforced across both stacks. |
Technical Constraints
- Backend: PHP 8.5 with a focus on modern API standards.
- Frontend: Next.js 16.
- Hosting/Ops: Containerized via Docker. Must be bootstrapped from the official
api-platform/api-platformtemplate repository (excluding Helm charts).
Decision Drivers
- Security — Never expose external API keys to the client payload.
- Standardization — Leverage established hypermedia API standards (JSON-LD, OpenAPI) out of the box instead of reinventing the wheel.
- Quality & DX — High static analysis levels, automated testing, and strict typings to prevent runtime errors.
- Separation of Concerns — Ensure the frontend remains a presentation and state layer, while the backend acts as a stateless, strictly typed computational engine.
Considered Options
Option A: Custom Symfony REST API Monolith
Building a custom REST API from scratch using bare Symfony 8 components (AbstractController, Serializer,
Validator).
Option B: "All-in-Frontend" Monolith (Next.js Only)
All GPX parsing, spatial calculations, and external API calls are executed directly in the browser or via Next.js Server Actions.
Option C: API Platform with Next.js SPA (Chosen)
A strict separation using API Platform (built on top of Symfony) for the backend to act as a stateless, standardized API engine, and a Next.js 16 Single Page Application (SPA) as the state manager and UI renderer.
Decision Outcome
Chosen: Option C (API Platform with Next.js SPA)
Why Other Options Were Rejected
Option A (Custom Symfony REST API) rejected:
- Requires manual maintenance of OpenAPI (Swagger) documentation.
- Misses out on API Platform's built-in hypermedia features (Hydra, JSON-LD) and automatic error normalization (RFC 7807).
- Reinventing serialization and deserialization flows that API Platform handles natively via DTOs and State Providers.
Option B (Next.js Only) rejected:
| Criterion | API Platform (Option C) | Next.js Only (Option B) |
|---|---|---|
| Security | API keys kept in PHP .env. Backend handles rate-limiting. |
Pushes domain logic and secret management to a boundary closer to the client. |
| Performance | PHP 8.5 JIT handles large GPX arrays efficiently. | Browser main thread blocked during heavy GPX parsing if done client-side. |
| Standards | Natively outputs OpenAPI v3 and JSON-LD. | Requires manual setup of Swagger/OpenAPI generators. |
Implementation Strategy
The architecture will be implemented by bootstrapping the project using the api-platform/api-platform template,
stripping out the Kubernetes/Helm configurations, and adapting it for a stateless (database-less) workflow.
1.1 — Docker Environment Bootstrap
We will use the Caddy-based setup provided by the API Platform distribution.
Action: Bootstrap from repository and clean up.
composer create-project api-platform/api-platform bike-trip-planner
cd bike-trip-planner
rm -rf helm/ # Remove Kubernetes charts
The resulting compose.yaml provides a highly optimized Caddy server for the backend and a Node container
for Next.js, ensuring HTTP/3 and native CORS configuration.
1.2 — Backend Implementation (Stateless API Platform)
Since there is no database (Doctrine), we will define ApiResource attributes directly on PHP Data Transfer Objects (
DTOs) and use State Providers and State Processors to handle the business logic.
Create: api/src/ApiResource/TripRequest.php
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\TripGenerationProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/generate-trip',
processor: TripGenerationProcessor::class,
output: TripResponse::class,
openapiContext: [
'summary' => 'Generates a bikepacking trip from a Komoot URL or GPX data.',
]
),
]
)]
final class TripRequest
{
#[Assert\NotBlank]
#[Assert\Url]
public string $komootUrl;
}
Create: api/src/State/TripGenerationProcessor.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\TripRequest;
use App\ApiResource\TripResponse;
use App\PacingEngine;
use App\OsmScanner;
final readonly class TripGenerationProcessor implements ProcessorInterface
{
public function __construct(
private PacingEngine $pacingEngine,
private OsmScanner $osmScanner
) {}
/**
* @param TripRequest $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TripResponse
{
// 1. Fetch GPX from $data->komootUrl
// 2. Execute PacingEngine
// 3. Scan OSM for alerts
// 4. Return new TripResponse DTO (automatically serialized by API Platform)
return new TripResponse(/* ... */);
}
}
1.3 — Frontend Implementation (State Manager)
Next.js will use Zustand for state management, persisting the data returned by API Platform to the browser's
localStorage.
File: pwa/src/store/useTripStore.ts
import {create} from 'zustand';
import {persist} from 'zustand/middleware';
import type {TripResponse} from '@/types/api';
interface TripState {
trip: TripResponse | null;
isLoading: boolean;
generateTrip: (komootUrl: string) => Promise<void>;
importFromJson: (jsonData: string) => void;
exportToJson: () => string;
}
export const useTripStore = create<TripState>()(
persist(
(set, get) => ({
trip: null,
isLoading: false,
generateTrip: async (komootUrl: string) => {
set({isLoading: true});
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_ENTRYPOINT}/generate-trip`, {
method: 'POST',
headers: {'Content-Type': 'application/ld+json'},
body: JSON.stringify({komootUrl})
});
const tripData = await response.json();
set({trip: tripData, isLoading: false});
} catch (error) {
set({isLoading: false});
throw error;
}
},
importFromJson: (jsonData) => set({trip: JSON.parse(jsonData)}),
exportToJson: () => JSON.stringify(get().trip, null, 2),
}),
{
name: 'bike-trip-planner-storage',
}
)
);
1.4 — Code Quality Standards
To ensure zero-ambiguity and prevent regressions, strict quality tools are enforced in both CI/CD and local hooks (e.g., Husky).
Backend Quality Tools:
- PHPStan: Level 9 (maximum strictness) to ensure perfect type safety across all DTOs and Providers.
- PHP-CS-Fixer: Configured with
@Symfonyand@PSR12rules for uniform formatting. - PHPUnit: Mandatory tests for custom
ProcessorInterfaceimplementations.
Frontend Quality Tools:
- TypeScript:
strict: trueintsconfig.json. Types will be generated automatically from API Platform's OpenAPI specification usingopenapi-typescript. - ESLint & Prettier: Strict Next.js core web vitals and React recommended rules.
- Playwright: End-to-end tests validating the full cycle (Input -> API Call -> Zustand State -> UI Render).
Verification
docker compose up --wait— successfully boots PHP and Node.js containers.curl -k https://localhost/docs.json— returns the OpenAPI specification generated automatically by API Platform.docker compose exec php vendor/bin/phpstan analyse -l 9 src/— returns zero errors.docker compose exec pwa npm run lint— returns zero ESLint errors.- Manual Test: Submitting a Komoot URL triggers the custom API Platform
TripGenerationProcessorand persists the standardized JSON-LD response in the browser's local storage.
Consequences
Positive
- API Standardization: The API natively supports JSON-LD, OpenAPI, and RFC 7807 (Problem Details for HTTP APIs), making the contract between PHP and Next.js unbreakable.
- Uncompromised Security: API keys for OSM and Weather are safely isolated in the PHP backend.
- DX (Developer Experience): The
api-platform/api-platformsetup provides a production-ready Docker configuration (Caddy) with built-in HTTPS and CORS out of the box.
Negative
- Learning Curve: Using API Platform without Doctrine ORM requires a deep understanding of custom State Providers and Processors, which is less conventional than traditional CRUD setups.
- Overhead: API Platform includes many features (GraphQL, Mercure) that might be overkill for a simple MVP,
requiring manual disabling in
api_platform.yamlto optimize performance.
Neutral
- The frontend must adapt to consuming JSON-LD formats (e.g., handling
@idand@typekeys) if strict hypermedia traversal is used.