Transporter: mobile Anwendung für Fahrer
TST Terschelling
MooiWeer
Eine vollständig entwickelte Anmelde- und Ticketplattform für einen Traillauf auf einer niederländischen Watteninsel — von Grund auf umgesetzt mit einem eigenen PHP-Framework, direkter Zahlungsabwicklung, PDF-Tickets und automatischem E-Mail-Versand.
TrailRun Terschelling ist die Anmeldewebsite für einen jährlichen Traillauf auf der niederländischen Insel Terschelling. Teilnehmer können sich online für 10 km, 15 km oder 25 km anmelden, mit verschiedenen in den Niederlanden üblichen Zahlungsarten bezahlen und sofort ein PDF-Ticket mit eindeutigem Zugangsschlüssel erhalten.
Das Projekt deckt den gesamten Teilnehmerablauf ab: Informationen mit interaktiven Streckenkarten, ein mehrstufiges AJAX-Anmeldeformular mit serverseitiger Prüfung, eine produktive Zahlungsanbindung, PDF-Erzeugung und automatische Transaktions-E-Mails. Alles wird durch ein eigenes schlankes MVC-Framework verbunden.
2025 wurde das gesamte Frontend neu gestaltet: mit neuer CSS-Architektur, Custom Properties, Flexbox-Layout und auf Mobilgeräte ausgerichteten Breakpoints. Zugleich wurde der PHP-Code auf PHP-8-Kompatibilität geprüft, während er weiterhin auf dem Produktionsserver mit seiner Beschränkung auf PHP 5.6 einsetzbar bleiben musste.
Statt Laravel oder Symfony verwendet das Projekt ein schlankes eigenes Framework, das speziell für solche Veranstaltungsanmeldungen entwickelt wurde. Es ist klein, schnell und technisch vollständig nachvollziehbar — ohne verborgene Mechanismen und unnötigen Ballast.
Erzeugt das HTML-Dokumentgerüst, ergänzt SEO-Metatags, lädt CSS- und JavaScript-Dateien und übergibt die Inhaltserstellung über eine abstrakte drawPage()-Methode an Unterklassen.
Erweitert NSWebpage um Sitzungsverwaltung und eine ereignisgesteuerte AJAX-Schicht. Eingehende Anfragen werden als NSEvent-Objekte ausgewertet und über einen handleEvent()-Dispatcher weitergeleitet.
Basisklasse aller datenbankgestützten Modelle. Ein Änderungsflag sorgt dafür, dass Eigenschaften nur bei Änderungen in die Datenbank geschrieben werden. Die Klasse bietet loadObject(), store() und delete() ohne zusätzlichen Aufwand für die dynamische Untersuchung des Schemas.
Eine Singleton-Hülle um mysqli stellt eine einzige Datenbankverbindung je Anfrage sicher. Sie bietet eine escape()-Hilfsfunktion und eine einheitliche Abfrageschnittstelle für alle Modelle.
Jede Route führt zu einer konkreten Seitenklasse, die TrailrunWebpage erweitert; diese Klasse erweitert wiederum NSActiveWebpage. Die einzelne Seiteninstanz wird in $_SESSION gespeichert. So bleibt der Formularzustand über AJAX-Aufrufe hinweg erhalten, ohne versteckte Felder oder wiederholte Datenbankzugriffe.
// All routing goes through .htaccess → index.php
// index.php routes to the correct page class
IndexPage → Home / hero slider / news
RegisterPage → Multi-step registration form
RoutesPage → Leaflet map + GPX route overlay
InformatiePage → Static race info content Die Anmeldung erfolgt über ein mehrstufiges AJAX-Formular, das jede Änderung mit dem Server abgleicht. Dadurch gehen vor der Zahlung keine Angaben verloren, wenn der Browser geschlossen wird oder die Sitzung abläuft.
Die Buchung ist in drei zusammenhängende Entitäten aufgeteilt, damit die Zuständigkeiten klar getrennt bleiben:
| Modell | Aufgabe | Zentrale Felder |
|---|---|---|
| Boeking | Hauptdatensatz der Buchung, Teilnehmerdaten und Zahlungsstatus | boekingsnummer, accesskey, state, email, birthdate |
| BoekingElement | Einzelpositionen, eine je gewählter Distanz beziehungsweise Startplatz | event_id, price, discount, quantity |
| BoekingPayment | Unveränderbares Protokoll der Zahlungstransaktionen | paymentid, amount, currency, brand, status, ipaddress |
| Event | Aus der Datenbank geladene Veranstaltungseinstellungen | eventname, adultprice, slots, minimumage, active |
Jede Buchung durchläuft festgelegte Zustände. Das verhindert doppelte Zahlungen und erleichtert Prüfungsabfragen:
STATENOPAYMENT (1) → Created, awaiting payment
STATEPAID (2) → Payment confirmed, PDF + email sent
STATECHANGE (3) → Booking modified post-payment
STATECANCELED (4) → Cancelled / refunded Die EventFactory fragt beim Laden der Seite die Datenbank ab und berechnet die verfügbaren Plätze je Distanz, indem sie bestätigte Buchungen vom festgelegten Maximum abzieht. Ist eine Distanz ausgebucht, wird sie automatisch aus der Auswahl entfernt, ohne manuellen Eingriff.
Der in jedem BoekingElement gespeicherte Zugangsschlüssel wird mit Toolbox::MakeRandomPassword(8) erzeugt: eine kryptografisch zufällige Zeichenfolge mit 8 Zeichen, die auf dem PDF-Ticket steht und vom Veranstaltungsteam am Start zur Teilnehmerprüfung genutzt wird.
Zahlungen laufen über das EMS e-Commerce Gateway. Es unterstützt die drei für eine niederländische Sportveranstaltung besonders relevanten Verfahren: iDEAL als direkte Bankzahlung, Mastercard und PayPal.
payment_ideal, payment_mastercard oder payment_paypal). Der Server ruft EMSPayment::sendPaymentRequest() auf. Diese Methode berechnet eine HMAC-Signatur über die Auftragsparameter und liefert eine Weiterleitungs-URL zurück.inschrijven.php. Die Verarbeitung prüft die HMAC-Signatur, um die Echtheit der Antwort sicherzustellen, und liest anschließend chargetotal, txndatetime und den Freigabecode aus.approval_code == 'Y' und status == 'APPROVED' wird die Buchung als bezahlt markiert, der vollständige Transaktionsdatensatz in boeking_payment gespeichert und die PDF- und E-Mail-Verarbeitung gestartet. Jedes andere Ergebnis führt zu PAYMENTFAILURE und einer verständlichen Fehlermeldung für den Benutzer.Die Codebasis enthält außerdem noch die alte Ogone-Zahlungsanbindung als Vorgänger. Sie ist nicht mehr aktiv, bleibt aber als Referenz erhalten, weil frühere Buchungen darüber abgewickelt wurden und das Antwortformat weiterhin in Kommentaren dokumentiert ist.
Jede bestätigte Anmeldung erzeugt ein persönliches, druckfertiges PDF-Ticket. Es wird serverseitig mit PDFlib erstellt, einer kommerziellen PDF-Bibliothek, die wegen ihrer präzisen Layoutsteuerung und zuverlässigen Schriftdarstellung gewählt wurde.
Die Ticket-Factory (framework/factories/PDFTicket.php) erstellt das Dokument per Code — ohne Vorlagendatei, HTML-zu-PDF-Konvertierung oder zwischengeschalteten Renderschritt. Jedes Element wird mit exakten Punktkoordinaten auf einer A4-Seite mit 595 × 842 pt platziert.
Ein markentypisches Bild über die gesamte Seitenbreite (pdf_ticketkop.jpg) steht am oberen Rand und macht Veranstaltung und Jahr erkennbar.
Je nach gebuchter Distanz wird zur Laufzeit die passende Karte (pdf_10KM.jpg, pdf_15KM.jpg oder pdf_25KM.jpg) geladen. So erhält jedes Ticket eine eindeutige visuelle Zuordnung zur Strecke.
Name, Adresse, Postleitzahl, Ort und gewählte Distanz werden in Helvetica-Bold gesetzt. Das Veranstaltungsdatum 28-02-2027 und die jeweilige Startzeit 13:00 / 12:30 / 11:45 stehen in großer Schrift.
Der Zugangsschlüssel mit 8 Zeichen wird für die Kontrolle am Start gut sichtbar gedruckt. Die Buchungsnummer erscheint in weißer Courier-Schrift auf dem dunklen Fußbereich, damit das Veranstaltungsteam sie schnell erfassen kann.
// Simplified excerpt from PDFTicket::BuildTicket()
$pdf = new PDFlib();
$pdf->set_option("license=m900102-...");
$pdf->begin_document("pdf_gen_tickets/{$nr}.pdf", "");
$pdf->begin_page_ext(595, 842, ""); // A4
// Place branded header image
$img = $pdf->load_image("jpeg", "pdf_ticketkop.jpg", "");
$pdf->fit_image($img, 0, 700, "boxsize={595 130}");
// Distance-specific route map
$routeImg = $pdf->load_image("jpeg", "pdf_{$km}KM.jpg", "");
$pdf->fit_image($routeImg, 300, 480, "boxsize={280 200}");
// Participant name in Helvetica-Bold 14pt
$font = $pdf->load_font("Helvetica-Bold", "winansi", "");
$pdf->setfont($font, 14);
$pdf->show_xy($naam, 40, 650);
// Amount in large 24pt type
$pdf->setfont($font, 24);
$pdf->show_xy("EUR {$amount}", 40, 560);
$pdf->end_page_ext("");
$pdf->end_document(""); Das fertige PDF wird unter pdf_gen_tickets/{BookingNumber}.pdf gespeichert und steht sofort als Anhang der Bestätigungs-E-Mail bereit.
PDFlib wurde gegenüber HTML-zu-PDF-Konvertern wie wkhtmltopdf bevorzugt, weil es ein deterministisches, pixelgenaues Layout bietet. Dafür ist eine kommerzielle Lizenz erforderlich. Bei einem Ticketsystem, dessen verschobene Elemente das Veranstaltungsteam verwirren könnten, lohnt sich diese Abwägung.
Bestätigungs-E-Mails werden unmittelbar nach der Zahlungsprüfung versendet — ohne Warteschlange und Verzögerung. Die PostOffice-Factory kapselt PHPMailer und leitet ausgehende Nachrichten für eine zuverlässige Zustellung über das SMTP-Relay von Mailgun.
framework/mail_templates/reserveringsbevestiging.html gelesen. Diese eigenständige HTML-Datei kann auch ohne Programmierkenntnisse bearbeitet werden, ohne PHP-Code anzutasten.[FIRSTNAME], [RESERVATIONNUMBER] und [BOOKINGDATA] werden zur Laufzeit durch die Angaben des Teilnehmers ersetzt. Die tabellarische Buchungsübersicht wird als direkt eingebettetes HTML erzeugt.pdf_gen_tickets/{BookingNumber}.pdf) wird vor dem Versand angehängt. Teilnehmer erhalten es innerhalb weniger Sekunden nach der Zahlungsbestätigung.smtp.eu.mailgun.org:25 an die Adresse des Teilnehmers. Eine verdeckte BCC-Kopie geht für die Dokumentation an die Veranstalter. Der Betreff enthält die Buchungsnummer: Inschrijf bevestiging TrailRun Terschelling 2027 [TRT-00123].// PostOffice::sendConformation() — core logic
$body = file_get_contents('framework/mail_templates/reserveringsbevestiging.html');
$body = str_replace('[FIRSTNAME]', $boeking->Voorletters(), $body);
$body = str_replace('[RESERVATIONNUMBER]', $boeking->Boekingsnummer(), $body);
$body = str_replace('[BOOKINGDATA]', $itemTable, $body);
$mail->AddAttachment("pdf_gen_tickets/{$nr}.pdf");
$mail->AddBCC('info@mooi-weer.nl');
$mail->Send(); Die Neugestaltung 2025 ersetzte ein veraltetes Layout durch ein modernes Designsystem mit Schwerpunkt auf Mobilgeräten. Der zugrunde liegende PHP-Code blieb dabei nach Möglichkeit unverändert. Die Bildsprache greift bewusst den rauen Outdoor-Charakter der Veranstaltung auf.
Ein einziger :root-Block definiert die gesamte Farbpalette und Abstandsskala. Markenfarben für ein künftiges Veranstaltungsjahr lassen sich damit an einer Stelle ändern, statt 2000 Zeilen durchsuchen und ersetzen zu müssen.
Breakpoints liegen bei 767 px und 1023 px. Auf Mobilgeräten wird die Navigation zum Hamburger-Menü. Die Distanzkarten wechseln ohne JavaScript für das Layout vom mehrspaltigen Raster zu einer einzigen Spalte.
Durchgängig klare Kanten: Jede Karte, Schaltfläche und Eingabe hat rechteckige Ecken, passend zur sachlichen Gestaltung von Startnummern, Wegmarkierungen und Geländekarten. Das ist eine bewusste Gestaltungsregel, kein Versehen.
Für jede Distanz wird eine eigene GPX-Strecke über Leaflet auf OpenStreetMap-Karten dargestellt. Die Liniengestaltung verwendet das Markengrün #99c038; animierte Markierungen zeigen wichtige Wegpunkte.
Die JavaScript-Klasse RegisterPage sendet bei jeder Feldänderung ein addressupdate-Ereignis an den Server. Serverseitige Validierungsfehler werden als JSON zurückgegeben und direkt am Formular angezeigt — ohne vollständiges Neuladen und ohne Datenverlust.
Auf der Startseite wechselt ein bildschirmfüllender Slider zwischen Veranstaltungsfotos. Wischgesten auf Mobilgeräten werden ohne zusätzliche Konfiguration unterstützt.
Das CSS wird in css/design.css mit 1752 Zeilen gepflegt und für den Produktivbetrieb zu design.min.css minimiert. Das JavaScript des Anmeldeformulars wird von js/inschrijven.js zu inschrijven.min.js minimiert. Die .htaccess aktiviert gzip-Komprimierung für CSS-, JavaScript- und Schriftdateien und setzt weitreichende Cache-Header für statische Dateien.
Die produktive Hosting-Umgebung ist auf PHP 5.6 beschränkt. Gleichzeitig muss der Code eine PHP-8-Syntaxprüfung bestehen und auf dem Entwicklungsrechner sauber unter PHP 8 laufen. Das ist die anspruchsvollste einzelne Vorgabe dieses Projekts.
2025 wurde jede PHP-Datei auf Funktionen geprüft, die sich zwischen diesen Versionen unterschiedlich verhalten:
mysql_query(), mysql_fetch_array() und verwandten Funktionen wurden durch ihre mysqli_*-Entsprechungen ersetzt, die sowohl unter PHP 5.6 als auch unter PHP 8 verfügbar sind.Iterator-Schnittstelle implementieren. Sowohl Iterator als auch IteratorAggregate gab es bereits vor PHP 8. Code, der unter PHP 5.6 laufen muss, kann keine Rückgabetypdeklarationen verwenden: Diese Version unterstützt die entsprechende Syntax nicht. Die Typen werden stattdessen in Docblocks dokumentiert. Ab PHP 8.1 sind zudem vorläufige Rückgabetypen interner Schnittstellen zu berücksichtigen. $str{0}) nicht mehr. Alle Vorkommen wurden auf die Syntax mit eckigen Klammern ($str[0]) umgestellt, die in beiden Versionen gültig ist.PHP-Code, der sowohl unter 5.6 als auch unter 8 läuft, ist schwieriger zu schreiben als Code für nur eine Version. Die Strategie lautete: ausschließlich den gemeinsamen Funktionsumfang verwenden und Funktionen vermeiden, die nach 5.6 eingeführt und deren Verhalten später in PHP 8 verändert wurde.
Jede Komponente wurde aus praktischen Gründen gewählt — kein Framework um seiner selbst willen und keine Abhängigkeit ohne konkreten Nutzen.
| Schicht | Technologie | Begründung |
|---|---|---|
| Serversprache | PHP 5.6 / 8 | Vorgabe der Hosting-Umgebung; keine Wahlmöglichkeit |
| Datenbank | MySQL (mysqli) | Auf dem Host verfügbar und für das Datenvolumen ausreichend |
| Framework | Eigenentwicklung (NSWebpage / MVC) | Laravel / Symfony auf PHP 5.6 nicht ohne Umwege einsetzbar |
| PDF-Erzeugung | PDFlib (kommerziell) | Pixelgenaues Layout, deterministische Ausgabe und Schrifteinbettung |
| E-Mail-Versand | PHPMailer + Mailgun SMTP | Zuverlässige Zustellung und BCC-Dokumentation für die Veranstalter |
| Zahlungsanbindung | EMS e-Commerce (HMAC) | Unterstützt iDEAL, Mastercard und PayPal über eine einzige Anbindung |
| Karten | Leaflet.js + OpenStreetMap | Open Source, kein API-Schlüssel erforderlich, einfache GPX-Einblendung |
| DOM-Manipulation | jQuery 1.11.1 | Stabil und kompatibel mit den älteren Browsern der Teilnehmer |
| Bildslider im Kopfbereich | Jssor | Wischgesten ohne Zusatzkonfiguration, keine Serverabhängigkeit |
| CSS-Architektur | Eigenentwicklung und CSS Custom Properties | Kein Präprozessor nötig, native Unterstützung für Custom Properties |
| Geräteerkennung | Mobile_Detect.php | Serverseitiger Hinweis für Breakpoints ergänzend zu Media Queries |
Was dieses Projekt über seine Funktionsliste hinaus zeigt.
Die Beschränkung auf PHP 5.6 führte zu einem eigenen Framework, das klein, vollständig nachvollziehbar und leicht prüfbar ist. Keine verborgenen Mechanismen und keine Versionskonflikte, die tief im Drittanbietercode stecken.
Das Änderungsflag-Muster in NSPersistentObject ist ein bewährter Ansatz, den auch viele große Frameworks verwenden. Manchmal ist es sinnvoller, ein kleines Rad neu zu bauen, als ein großes hinzuzunehmen.
Die Speicherung der Seiteninstanz in $_SESSION und die Synchronisierung nach jeder Feldänderung schützen Teilnehmerdaten selbst dann, wenn der Browser mitten im Formular abstürzt. Versteckte Eingabefelder würden das Neuladen des Tabs nicht überstehen.
HTML-zu-PDF-Konverter sind bequem, liefern aber schwankende Ergebnisse. Für ein Ticketsystem, dessen Layoutgenauigkeit im Betrieb wichtig ist, war eine vollwertige PDF-Bibliothek die Lizenzgebühr wert.
Der Verzicht auf abgerundete Ecken begann als zeitsparende Vereinfachung und wurde zur bewussten Markenentscheidung. Klare Kanten passen zu einem Lauf durch Dünen, Wald und Strand.
Gleichzeitig für PHP 5.6 und PHP 8 zu entwickeln ist ungewöhnlich, aber lösbar. Entscheidend ist, genau zu wissen, welche Funktionen vermieden werden müssen, und diese Vorgabe für künftige Mitwirkende klar zu dokumentieren.
Weitere Arbeiten
TST Terschelling
TransportMaster
TransportMaster