292 lines
8.8 KiB
Markdown
292 lines
8.8 KiB
Markdown
# Symfony Entwicklungs-Dokumentation
|
||
|
||
## 1. Provider/Processor-Pattern
|
||
|
||
### Pattern-Struktur
|
||
Alle API-Anfragen durchlaufen zwei Phasen:
|
||
- **Provider**: Daten aus verschiedenen Quellen zusammenführen, Validierung vorbereiten, Domain-Logik entkoppeln
|
||
- **Processor**: Daten persistieren, Events auslösen, Response aufbauen
|
||
|
||
### Beispiel: Anwesenheit registrieren
|
||
|
||
```php
|
||
// src/Provider/AnwesenheitCreateProvider.php
|
||
final class AnwesenheitCreateProvider
|
||
{
|
||
public function __construct(
|
||
private VeranstaltungRepository $vr,
|
||
private PersonenRepository $pr,
|
||
private MandantIdScope $scope
|
||
) {}
|
||
|
||
public function provides(CreateAnwesenheitRequest $request): AnwesenheitCommand
|
||
{
|
||
$veranstaltung = $this->vr->findScop($request->veranstaltungId, $this->scope);
|
||
if (!$veranstaltung) {
|
||
throw new VeranstaltungNotFoundException($request->veranstaltungId);
|
||
}
|
||
|
||
return new AnwesenheitCommand(
|
||
veranstaltung: $veranstaltung,
|
||
personenId: $request->personenId,
|
||
status: $request->status
|
||
);
|
||
}
|
||
}
|
||
|
||
// src/Processor/AnwesenheitCreateProcessor.php
|
||
final class AnwesenheitCreateProcessor
|
||
{
|
||
public function __construct(
|
||
private EntityManagerInterface $em,
|
||
private EventDispatcherInterface $dispatcher
|
||
) {}
|
||
|
||
public function process(AnwesenheitCommand $command): AnwesenheitCreatedEvent
|
||
{
|
||
$anwesenheit = new Anwesenheit();
|
||
$anwesenheit->setVeranstaltung($command->veranstaltung);
|
||
$anwesenheit->setPersonenId($command->personenId);
|
||
// ...
|
||
|
||
$this->em->persist($anwesenheit);
|
||
$this->em->flush();
|
||
|
||
$event = new AnwesenheitCreatedEvent($anwesenheit);
|
||
$this->dispatcher->dispatch($event);
|
||
|
||
return $event;
|
||
}
|
||
}
|
||
```
|
||
|
||
|
||
### Controller-Einbindung
|
||
```php
|
||
#[Route('/veranstaltungen/{id}/anwesenheit', name: 'erstellen_anwesenheit', methods: ['POST'])]
|
||
public function create(CreateAnwesenheitRequestDto $dto, RequestCreator $creator): JsonResponse
|
||
{
|
||
try {
|
||
$command = $this->providerProvider->provides($dto);
|
||
$result = $this->processorProvider->process($command);
|
||
return new JsonResponse(['data' => $result], 201);
|
||
} catch (EntityNotFoundException $e) {
|
||
return JsonResponse::createNotFound($e->getMessage());
|
||
}
|
||
}
|
||
```
|
||
|
||
### Muster-Regeln
|
||
- Provider enthalten **keine** Persistenz-Logik
|
||
- Processor enthalten **keine** Validierung-Logik
|
||
- Beide sind **stateless** und leicht testbar
|
||
- Commands und Events als Data Transfer Objekte (POPOs)
|
||
|
||
---
|
||
|
||
## 2. Coding Standards
|
||
|
||
### PHP Version & Basis
|
||
- PHP >= 8.4 (LTS empfohlen)
|
||
- PSR-12 als Code-Stil-Richtlinie
|
||
- PHPStan Level 6-7
|
||
|
||
### Naming Conventions
|
||
| Typ | Konvention | Beispiel |
|
||
|-----|-----------|----------|
|
||
| Klassen | PascalCase + Substantiv | `AnwesenheitController` |
|
||
| Methoden | camelCase + Verb oder Aktonsnamen | `registriereAnwesenheit()` |
|
||
| Variablen | camelCase | `$veranstaltungsId` |
|
||
| Constants | UPPER_SNAKE_CASE | `MAX_TEILNEHMER` |
|
||
|
||
### Controller-Konventionen
|
||
- Keine Domain-Logik – immer delegieren an Provider/Processor
|
||
- DTOs für Request/Response (keine Entities direkt)
|
||
|
||
|
||
```json
|
||
{
|
||
"data": {
|
||
"id": 1,
|
||
"status": "present"
|
||
},
|
||
"meta": {
|
||
"message": "Anwesenheit erfolgreich registriert"
|
||
}
|
||
}
|
||
```
|
||
|
||
### Exception Handling (→ siehe Abschnitt 3)
|
||
- Domain Exceptions werfen (keine raw Symfony Exceptions nach außen)
|
||
- Nur `JsonResponse` mit definiertem Error-Schema zurückgeben
|
||
|
||
---
|
||
|
||
## 3. Fehlerbehandlung & API Error Schema
|
||
|
||
### Exceptions-Hierarchie
|
||
Domain Exceptions → ControllerExceptionHandler → JSON Response
|
||
|
||
```php
|
||
// Domain Exception
|
||
class VeranstaltungNotFoundException extends RuntimeException implements DomainExceptionInterface {}
|
||
|
||
// Controller Exception Handler (Kernel-Register)
|
||
#[AsController]
|
||
class ControllerExceptionHandler
|
||
{
|
||
public function __invoke(DomainExceptionInterface $e): JsonResponse
|
||
{
|
||
return new JsonResponse([
|
||
'errors' => [[
|
||
'status' => 422, // oder je nach Situation
|
||
'title' => ucfirst($e->getType()),
|
||
'detail' => $e->getMessage(),
|
||
]]
|
||
], 422);
|
||
}
|
||
}
|
||
```
|
||
|
||
### HTTP Status Codes
|
||
| Code | Verwendigunbg |
|
||
|------|-----------|
|
||
| `200` | OK (GET, PUT) |
|
||
| `201` | Created (POST, Entity erstellt) |
|
||
| `400` | Invalid Request Body / Validation Error |
|
||
| `403` | Forbidden (Mandant fehlt) |
|
||
| `404` | Not Found |
|
||
| `500` | Internal Server Error |
|
||
|
||
---
|
||
|
||
## 4. Logging & Mercure
|
||
|
||
### Events-Logging-Logik
|
||
Jeder Processor löst nach Persistierung Events aus:
|
||
```php
|
||
$this->dispatcher->dispatch(new AnwesenheitAktualisiertEvent($anwesenheit));
|
||
```
|
||
|
||
Events enthalten:
|
||
- Entity-Daten (keine sensiblen)
|
||
- Timestamp
|
||
- MandantID scope
|
||
- ActionTyp ('created', 'updated')
|
||
|
||
### Mercure Integration - Vorschläge & Nutzungsszenarien
|
||
- **Echtzeit-Anwesenheitsliste**: Websocket oder Server-Sent Events über Mercure Hub
|
||
- **Live-Benachrichtigungen** bei Statusänderungen
|
||
|
||
#### Konfigurationsoptionen für Mercure:
|
||
1. **Pulled** (Clientseitig abonnieren): Client ruft `/mercure/hub` Periodisch ab
|
||
2. **Pushed** (Serverseitig publish): Processor publiziert Events direkt an Hub
|
||
3. **Hybrid**: Pushed bei User-Aktion (z.b. Anwesenheit registrieren), Pulled bei Statusabfragen
|
||
|
||
**Empfehlung**: Mercure als optional, nur wenn Echtzeit-Funktionalität wirklich gebraucht wird. Nicht default aktivieren.
|
||
|
||
---
|
||
|
||
## 5. Datenbank/Migrationen - Entwicklungskonventionen
|
||
|
||
|
||
|
||
### Doctrine Entity-Spezifikation
|
||
- **Entities** POPOs (Plain Old PHP Objects) mit Annotationen/Attributes für Mapping
|
||
- **Repository** nur Domain-spezifische Queries, kein raw SQL
|
||
- **Keine** Business-Logik in Entities – stattdessen Services / Provider / Processor
|
||
- **Idempotenz**: Updates per primary key
|
||
|
||
### Migrationen-Konventionen
|
||
- Jede Tabelle hat `mandant_id`, `created_at`, `updated_at` (Timestamp)
|
||
- Fremdschlüssel immer als Index + NOT NULL
|
||
|
||
|
||
- Migrations in Version kontrolliert: `php bin/console make:migration`
|
||
- **Keine** manuellen Schema-Changes ohne Migration
|
||
|
||
### Multi-Tenant-Scope Enforcement
|
||
```
|
||
Entity::mandant_id → Unique across Mandanten. Alle Queries scoped per MandantIdContext (z.B. durch Attribute/Listener)
|
||
```
|
||
|
||
---
|
||
|
||
## 6. TDD Workflow (Red-Green-Refactor)
|
||
|
||
### Test-Schichten Priorität (TDD Red-Green)
|
||
1. **Controller/Route Tests** – Request→Response, Status-Codes, JSON Schema
|
||
2. **Provider/Processor Unittests** – isolierte logik-test der einzelnen Domäne-Layer
|
||
3. **Integrationstests** – wenn nötig (DB-Kontext, Services)
|
||
|
||
### Test-Struktur in PHPUnit
|
||
```
|
||
api/tests/Unit/Provider/
|
||
├── AnwesenheitCreateProviderTest.php ← Provider ohne DB (mock repository)
|
||
└── VeranstaltungFindProviderTest.php
|
||
|
||
api/tests/Unit/Processor/
|
||
└── AnwesenheitCreateProcessorTest.php ← Processor mit mock EM
|
||
|
||
api/tests/Functional/Controller/
|
||
└── AnwesenheitControllerTest.php ← Full HTTP request via HttpBrowser
|
||
```
|
||
|
||
### Test-Beispiel PHPUnit
|
||
```php
|
||
// tests/Unit/Provider/AnwesenheitCreateProviderTest.php
|
||
class AnwesenheitCreateProviderTest extends TestCase
|
||
{
|
||
public function testProvidesCommandWhenVeranstaltungFound(): void
|
||
{
|
||
$veranstaltung = new Veranstaltung();
|
||
$request = new CreateAnwesenheitRequestDto(123);
|
||
|
||
$provider = new AnwesenheitCreateProvider( // Mocks injected
|
||
VeranstaltungRepositoryMock($veranstaltung),
|
||
MandantIdScope('mandant-xyz')
|
||
);
|
||
|
||
$result = $provider->provides($request);
|
||
|
||
self::assertInstanceOf(AnwesenheitCommand::class, $result);
|
||
self::assertSame($veranstaltung, $result->getVeranstaltung());
|
||
}
|
||
}
|
||
```
|
||
|
||
### Test-Prioritäten für Implementierung
|
||
1. Controller/Routen-Tests schreiben → **failend** (RED)
|
||
2. Minimal-Implementierung schreiben → **test bestanden** (GREEN)
|
||
3. Refactor (Code-Qualität, Tests verbessern)
|
||
4. Nächster Test (höhere Abdeckung im Provider/Processor)
|
||
|
||
---
|
||
|
||
## 7. Framework-Bundles
|
||
|
||
### Philosophie
|
||
- Kern-Symfony nur verwenden (Mime, HttpFoundation, Serializer, Validator, Security)
|
||
- Neue Bundles **nur bei Bedarf** dokumentieren und begründen
|
||
|
||
|
||
| Bundle | Verwendung | Begründung erforderlich |
|
||
|--------|------------|------------------------|
|
||
| `symfony/maker` | Boilerplate | Nur für Initialisierung, nicht produktiv |
|
||
| `api-platform/core` | REST API | **Explizit vermieden** – manuelle Controller |
|
||
| `sensio/framework-extra-bundle` | Annotations/Cache | Symfony 7.4 native statt Bundles bevorzugen |
|
||
| `symfonycasts/validator` | Custom Validators | Nur bei komplexer Validierung |
|
||
| `mercure/bundle` | Mercure-Integration | Nur wenn Echtzeit benötigt wird |
|
||
|
||
---
|
||
|
||
## Zusammenfassung Entwicklungsflow
|
||
1. Test schreiben (RED) → failend
|
||
2. Minimal-Implementierung als Provider/Processor/Gateway
|
||
3. Test bestehen lassen (GREEN)
|
||
4. Refaktorieren und Qualität prüfen (PHPStan, Coding Standards)
|
||
5. Entity/Migration erstellen wenn nötig **nach** Code-Phase
|
||
|
||
Die Doku wird mit dem Projekt aktualisiert.
|
||
|