HTMX
HTMX ist die einzige erlaubte Methode für AJAX-Interaktionen im Campus-System.
Warum HTMX?
| Aspekt | HTMX | fetch()/XHR |
|---|---|---|
| CSRF-Enforcement | Automatisch via Contract | Manuell, fehleranfällig |
| Konsistenz | Deklarativ im HTML | Imperativ in JS verstreut |
| Server-Autorität | Ja | Client-State-Chaos möglich |
| Progressive Enhancement | Ja | Nein |
| Debugging | HTML-Attribute sichtbar | DevTools nötig |
Contract #14: htmx-patterns
Alle HTMX-Regeln sind in Contract #14 definiert.
Kritische Regeln
| ID | Regel | Enforcement |
|---|---|---|
| HTMX-C1 | hx-post MUSS hx-headers mit X-CSRF-TOKEN haben | Contract-Validierung |
| HTMX-C2 | hx-delete MUSS hx-headers mit X-CSRF-TOKEN haben | Contract-Validierung |
| HTMX-C3 | hx-patch MUSS hx-headers mit X-CSRF-TOKEN haben | Contract-Validierung |
| HTMX-C4 | hx-delete MUSS hx-confirm Attribut haben | Contract-Validierung |
| HTMX-C5 | hx-put MUSS hx-headers mit X-CSRF-TOKEN haben | Contract-Validierung |
Hinweis: Die Regeln werden derzeit durch Contract-Validierung (manuell oder via contracts_validate) geprüft. Ein automatischer Pre-Hook ist geplant aber noch nicht aktiv.
Empfohlene Regeln (WARN)
| ID | Regel | Enforcement |
|---|---|---|
| HTMX-R1 | Kein fetch() in View-Dateien | Contract-Validierung |
| HTMX-R2 | hx-indicator für Loading-States | Dokumentation |
| HTMX-R3 | hx-swap explizit angeben | Dokumentation |
| HTMX-R4 | hx-disabled-elt für Button-States | Dokumentation |
Standard-Patterns
1. Button mit POST-Action
<button hx-post="/api/endpoint"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-target="#result"
hx-swap="innerHTML"
hx-indicator="this"
hx-disabled-elt="this">
<span class="htmx-content">Speichern</span>
<span class="htmx-indicator">Wird gespeichert...</span>
</button>
2. Delete mit Confirmation
<button hx-delete="/api/items/<?= $id ?>"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-confirm="Wirklich löschen?"
hx-target="closest tr"
hx-swap="outerHTML swap:0.3s">
Löschen
</button>
3. Form mit PUT
<form hx-put="/api/items/<?= $id ?>"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-target="#form-message"
hx-swap="innerHTML"
hx-indicator="#save-btn"
hx-disabled-elt="#save-btn">
<!-- Form fields -->
<button type="submit" id="save-btn">Speichern</button>
</form>
<div id="form-message"></div>
4. Inline-Edit
<input type="text"
name="value"
value="<?= htmlspecialchars($value) ?>"
hx-post="/api/items/<?= $id ?>/field"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-trigger="blur, keyup[key=='Enter']"
hx-swap="none"
hx-disabled-elt="this"
hx-on::after-request="this.classList.toggle('is-saved', event.detail.successful)">
5. Select mit Auto-Save
<select name="status"
hx-post="/api/items/<?= $id ?>/status"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-swap="none"
hx-disabled-elt="this">
<option value="draft">Entwurf</option>
<option value="active">Aktiv</option>
</select>
Controller-Integration
Basis-Controller Methoden
// In Framework\Controller
// CSRF validieren (wirft 403 bei Fehler)
$this->requireCsrf();
// Erfolg-Alert (via HX-Retarget zu #htmx-messages)
$this->htmxSuccess('Gespeichert!');
// Fehler-Alert
$this->htmxError('Fehler beim Speichern');
// Redirect (via HX-Redirect Header)
$this->htmxRedirect('/items/' . $id);
Beispiel-Controller
public function update(int $id): void
{
$this->requireCsrf();
try {
$data = $this->getJsonInput();
$this->useCase->update($id, $data);
$this->htmxSuccess('Gespeichert');
} catch (\Exception $e) {
$this->htmxError($e->getMessage());
}
}
Layout-Anforderungen
Globaler Message-Container
In layout.php muss dieser Container existieren:
<main>
<div id="htmx-messages" class="htmx-messages" aria-live="polite"></div>
<?= $content ?>
</main>
CSS für HTMX-States
/* Message-Container */
.htmx-messages {
position: fixed;
top: 80px;
right: var(--space-lg);
z-index: 1000;
max-width: 400px;
}
/* Loading-States */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request .htmx-content { display: none; }
/* Disabled während Request */
.htmx-request[hx-disabled-elt] {
opacity: 0.6;
pointer-events: none;
}
/* Erfolgs-Feedback */
.is-saved {
border-color: var(--color-success);
animation: flash-success 0.5s;
}
Enforcement
Contract-Validierung
Die HTMX-Regeln werden via Contract #14 (htmx-patterns) validiert:
# Manuelle Validierung
contracts_validate(name="htmx-patterns")
# Oder via Script
/var/www/scripts/contract-check.sh
Geplant: Pre-Hook
Ein automatischer Pre-Hook (pre_rules_htmx.py) für Write/Edit-Operationen ist geplant, aber noch nicht in der Hook-Konfiguration aktiviert.
Ausnahmen
HTMX ist nicht geeignet für:
- Echtzeit-Updates (SSE/WebSocket nötig)
- Komplexe Client-Side-Validierung
- Offline-Funktionalität
Für diese Fälle: Explizite Ausnahme dokumentieren und minimal JS verwenden.
Verwandte Themen
- HTMX Patterns Referenz - Alle Patterns im Detail
- HTMX Troubleshooting - Häufige Probleme
- Regeln - HTMX statt fetch() Regel
HTMX Patterns Referenz
Kopierbare Patterns für häufige Anwendungsfälle.
CRUD-Operationen
Create (POST)
<form hx-post="/api/items"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-target="#form-message"
hx-swap="innerHTML"
hx-indicator="#create-btn"
hx-disabled-elt="#create-btn">
<input type="text" name="title" required>
<textarea name="description"></textarea>
<button type="submit" id="create-btn" class="btn btn--primary">
<span class="htmx-content">Erstellen</span>
<span class="htmx-indicator">Erstelle...</span>
</button>
</form>
<div id="form-message"></div>
Read (GET mit Partial)
<div hx-get="/api/items/<?= $id ?>/details"
hx-trigger="revealed"
hx-swap="innerHTML">
Lade Details...
</div>
Update (PUT)
<form hx-put="/api/items/<?= $id ?>"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-target="#form-message"
hx-swap="innerHTML"
hx-indicator="#save-btn"
hx-disabled-elt="#save-btn">
<input type="text" name="title" value="<?= htmlspecialchars($item['title']) ?>">
<button type="submit" id="save-btn" class="btn btn--primary">
<span class="htmx-content">Speichern</span>
<span class="htmx-indicator">Speichere...</span>
</button>
</form>
<div id="form-message"></div>
Delete (DELETE)
<button hx-delete="/api/items/<?= $id ?>"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-confirm="'<?= htmlspecialchars($item['title']) ?>' wirklich löschen?"
hx-target="closest tr"
hx-swap="outerHTML swap:0.3s"
class="btn btn--danger">
Löschen
</button>
Inline-Editing
Text-Input mit Auto-Save
<input type="text"
name="title"
value="<?= htmlspecialchars($value) ?>"
class="inline-edit"
hx-post="/api/items/<?= $id ?>/title"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-trigger="blur changed, keyup[key=='Enter'] changed"
hx-swap="none"
hx-disabled-elt="this"
hx-on::after-request="this.classList.toggle('is-saved', event.detail.successful); setTimeout(() => this.classList.remove('is-saved'), 1000)">
Select mit Auto-Save
<select name="status"
class="inline-select"
hx-post="/api/items/<?= $id ?>/status"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-swap="none"
hx-disabled-elt="this"
hx-on::after-request="this.classList.toggle('is-saved', event.detail.successful); setTimeout(() => this.classList.remove('is-saved'), 1000)">
<?php foreach ($statuses as $key => $label): ?>
<option value="<?= $key ?>" <?= $item['status'] === $key ? 'selected' : '' ?>>
<?= htmlspecialchars($label) ?>
</option>
<?php endforeach; ?>
</select>
Toggle (Checkbox)
<input type="checkbox"
name="is_active"
<?= $item['is_active'] ? 'checked' : '' ?>
hx-post="/api/items/<?= $id ?>/toggle-active"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-swap="none"
hx-disabled-elt="this">
Listen & Tabellen
Lazy-Load Tabelle
<table>
<thead>...</thead>
<tbody hx-get="/api/items?page=1"
hx-trigger="revealed"
hx-swap="innerHTML">
<tr><td colspan="5">Lade Daten...</td></tr>
</tbody>
</table>
Infinite Scroll
<div id="items-list">
<?php foreach ($items as $item): ?>
<div class="item"><?= htmlspecialchars($item['title']) ?></div>
<?php endforeach; ?>
<?php if ($hasMore): ?>
<div hx-get="/api/items?page=<?= $page + 1 ?>"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-indicator="#load-more-spinner">
<span id="load-more-spinner" class="htmx-indicator">Lade mehr...</span>
</div>
<?php endif; ?>
</div>
Sortierbare Spalten
<th hx-get="/api/items?sort=title&dir=<?= $sortDir === 'asc' ? 'desc' : 'asc' ?>"
hx-target="#items-tbody"
hx-swap="innerHTML"
class="sortable <?= $sortCol === 'title' ? 'sorted-' . $sortDir : '' ?>">
Titel
</th>
Suche & Filter
Live-Search
<input type="search"
name="q"
placeholder="Suchen..."
hx-get="/api/items/search"
hx-trigger="input changed delay:300ms, search"
hx-target="#search-results"
hx-swap="innerHTML"
hx-indicator="#search-spinner">
<span id="search-spinner" class="htmx-indicator">...</span>
<div id="search-results"></div>
Filter-Form
<form hx-get="/api/items"
hx-trigger="change from:select, change from:input[type='checkbox']"
hx-target="#items-list"
hx-swap="innerHTML"
hx-indicator="#filter-spinner">
<select name="status">
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="draft">Entwurf</option>
</select>
<select name="category">
<option value="">Alle Kategorien</option>
</select>
<span id="filter-spinner" class="htmx-indicator">Filtere...</span>
</form>
Modals & Dialogs
Modal laden
<button hx-get="/api/items/<?= $id ?>/edit-modal"
hx-target="#modal-container"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('modal-container').showModal()">
Bearbeiten
</button>
<dialog id="modal-container"></dialog>
Modal-Inhalt (Partial)
<form hx-put="/api/items/<?= $id ?>"
hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}'
hx-target="#modal-container"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful) document.getElementById('modal-container').close()">
<h2>Bearbeiten</h2>
<input type="text" name="title" value="<?= htmlspecialchars($item['title']) ?>">
<button type="submit">Speichern</button>
<button type="button" onclick="this.closest('dialog').close()">Abbrechen</button>
</form>
Polling & Updates
Auto-Refresh
<div hx-get="/api/status"
hx-trigger="every 5s"
hx-swap="innerHTML">
Status: <?= $status ?>
</div>
Polling mit Stop-Condition
<div hx-get="/api/jobs/<?= $jobId ?>/status"
hx-trigger="<?= $job['status'] === 'running' ? 'every 2s' : 'none' ?>"
hx-swap="outerHTML">
Status: <?= $job['status'] ?>
<?php if ($job['status'] === 'running'): ?>
<span class="spinner"></span>
<?php endif; ?>
</div>
Verwandte Themen
- HTMX Übersicht - Grundlagen und Konfiguration
- Troubleshooting - Häufige Probleme
- Regeln - HTMX statt fetch() Regel
HTMX Troubleshooting
Häufige Probleme
1. CSRF-Token Fehler (403)
| Symptom | Request schlägt mit 403 fehl |
|---|---|
| Ursache | hx-headers fehlt oder Token ist ungültig |
| Lösung | hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}' |
Prüfen:
- DevTools → Network → Request Headers → X-CSRF-TOKEN vorhanden?
- Token in Session gültig?
2. hx-indicator zeigt nichts an
| Symptom | Loading-Spinner erscheint nicht |
|---|---|
| Ursache | hx-indicator zeigt auf Element, CSS fehlt |
Lösung:
<!-- Indicator braucht ID oder Selektor -->
<button hx-indicator="#my-spinner">...</button>
<span id="my-spinner" class="htmx-indicator">Loading...</span>
/* CSS muss vorhanden sein */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
3. Response wird nicht angezeigt
| Symptom | Request erfolgreich, aber nichts passiert |
|---|---|
| Ursachen |
|
Debugging:
<!-- Temporär hinzufügen -->
hx-on::after-request="console.log('Response:', event.detail.xhr.responseText)"
4. Button bleibt disabled
| Symptom | Nach Request bleibt Button deaktiviert |
|---|---|
| Ursache | hx-disabled-elt wird nicht zurückgesetzt bei Fehler |
| Lösung | HTMX macht das automatisch. Prüfen ob JS den Button manuell disabled. |
5. Doppelte Requests
| Symptom | Ein Klick löst mehrere Requests aus |
|---|---|
| Ursachen |
|
Lösung:
<!-- Bei Button in Form -->
<button type="button" hx-post="..."> <!-- type="button" statt submit -->
<!-- Oder -->
<form hx-post="..." hx-trigger="submit">
<button type="submit">Submit</button> <!-- Nur form hat hx-post -->
</form>
6. Form-Daten werden nicht gesendet
| Symptom | POST-Request hat leeren Body |
|---|---|
| Ursache | hx-* auf Button statt Form |
Falsch:
<form>
<input name="title">
<button hx-post="/api">Send</button>
</form>
Richtig:
<form hx-post="/api">
<input name="title">
<button type="submit">Send</button>
</form>
7. History/Back-Button Probleme
| Symptom | Browser-Back funktioniert nicht wie erwartet |
|---|---|
| Ursache | HTMX ersetzt Inhalte ohne History-Push |
Lösung:
<!-- Für Navigation, die History braucht -->
<a href="/page" hx-push-url="true" hx-get="/page" hx-target="#main">Link</a>
<!-- Oder: Normaler Link ohne HTMX -->
<a href="/page">Link</a>
Debugging-Tools
1. HTMX Debug-Extension
<script src="https://unpkg.com/htmx.org/dist/ext/debug.js"></script>
<body hx-ext="debug">
Loggt alle HTMX-Events in die Console.
2. Request/Response in DevTools
- DevTools → Network
- XHR/Fetch filtern
- Request Headers prüfen (X-CSRF-TOKEN, HX-Request)
- Response Body prüfen
3. HTMX Events manuell loggen
document.body.addEventListener('htmx:beforeRequest', (e) => {
console.log('Before:', e.detail);
});
document.body.addEventListener('htmx:afterRequest', (e) => {
console.log('After:', e.detail);
console.log('Response:', e.detail.xhr.responseText);
});
document.body.addEventListener('htmx:responseError', (e) => {
console.error('Error:', e.detail);
});
Contract-Validierung Fehlermeldungen
Bei Contract-Validierung (contracts_validate(name="htmx-patterns")) können folgende Fehler auftreten:
"hx-post missing CSRF token"
| Bedeutung | HTMX-C1 verletzt |
|---|---|
| Fix | hx-headers='{"X-CSRF-TOKEN": "<?= $csrfToken ?>"}' |
"hx-delete missing confirmation"
| Bedeutung | HTMX-C4 verletzt |
|---|---|
| Fix | hx-confirm="Wirklich löschen?" |
Best Practices
1. Immer explizites Target
<!-- Gut -->
hx-target="#result-container"
<!-- Vermeiden (ersetzt Element selbst) -->
hx-target="this"
2. Expliziter Swap-Modus
<!-- Gut -->
hx-swap="innerHTML"
<!-- Vermeiden (Default ist innerHTML, aber explizit ist klarer) -->
(kein hx-swap)
3. Feedback bei hx-swap="none"
<!-- Bei hx-swap="none" braucht User visuelles Feedback -->
<input hx-swap="none"
hx-on::after-request="this.classList.add('is-saved'); setTimeout(() => this.classList.remove('is-saved'), 1000)">
Verwandte Themen
- HTMX Übersicht - Grundlagen und Konfiguration
- Patterns - Alle Code-Patterns
- Regeln - HTMX statt fetch() Regel