Transporter Mobile Application
TST Terschelling
MooiWeer
A full-stack registration and ticketing platform for a trail running event on a Dutch Wadden Sea island — built from scratch with a custom PHP framework, live payment processing, PDF ticket generation, and automated email delivery.
TrailRun Terschelling is the race registration website for an annual trail running event on the island of Terschelling in the Netherlands. Participants can sign up online for the 10 km, 15 km, or 25 km distance, pay through multiple Dutch payment methods, and instantly receive a PDF race ticket with a unique access key.
The project covers the entire participant journey end-to-end: an informational front end with race routes visualised on interactive maps, a multi-step AJAX registration form with server-side validation, a live payment gateway integration, a PDF generation pipeline, and an automated transactional email system — all tied together by a homegrown lightweight MVC framework.
In 2025 the entire front end was redesigned (new CSS architecture, custom properties, flexbox layout, mobile-first breakpoints) and the underlying PHP was audited for PHP 8 compatibility while keeping the codebase deployable on the production server which is constrained to PHP 5.6.
Rather than reaching for Laravel or Symfony, this project is built on a lightweight custom framework developed specifically for this type of event-registration use case. The framework is small, fast, and fully understood by the developer — no magic, no bloat.
Renders the HTML document skeleton, injects SEO meta tags, loads CSS/JS assets, and delegates content to subclasses through an abstract drawPage() method.
Extends NSWebpage with session management and an event-driven AJAX layer. Incoming requests are parsed into NSEvent objects and routed through a handleEvent() dispatcher.
Base class for all database-backed models. Implements a dirty-flag pattern — properties are only written to the database when changed. Provides loadObject(), store(), and delete() without any schema reflection overhead.
A singleton wrapper around mysqli that ensures a single database connection per request. Exposes an escape() helper and a consistent query interface used by all models.
Each route maps to a concrete page class that extends TrailrunWebpage (itself extending NSActiveWebpage). The page singleton is stored in $_SESSION, preserving form state across AJAX calls without hidden fields or repeated database round-trips.
// 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 Registration is handled through a multi-step AJAX form that synchronises state with the server on every change, so nothing is lost if the browser is closed or the session expires before payment.
The booking is decomposed into three related entities, keeping concerns cleanly separated:
| Model | Role | Key fields |
|---|---|---|
| Boeking | Primary booking record, participant info, payment state | boekingsnummer, accesskey, state, email, birthdate |
| BoekingElement | Individual line items (one per distance/slot selected) | event_id, price, discount, quantity |
| BoekingPayment | Immutable payment transaction log | paymentid, amount, currency, brand, status, ipaddress |
| Event | Race configuration loaded from database | eventname, adultprice, slots, minimumage, active |
Every booking moves through a strict set of states, preventing double-payments and making audit queries trivial:
STATENOPAYMENT (1) → Created, awaiting payment
STATEPAID (2) → Payment confirmed, PDF + email sent
STATECHANGE (3) → Booking modified post-payment
STATECANCELED (4) → Cancelled / refunded The EventFactory queries the database at page load and calculates the number of available slots per distance by subtracting confirmed bookings from the configured maximum. When a distance sells out, it is automatically removed from the selection UI without any manual intervention.
The access key stored on every BoekingElement is generated with Toolbox::MakeRandomPassword(8) — a cryptographically random 8-character string printed on the PDF ticket and used by race staff to verify participants at the start line.
Payments are processed through the EMS e-Commerce Gateway, which supports the three methods most relevant to a Dutch sporting event: iDEAL (direct bank transfer), Mastercard, and PayPal.
payment_ideal, payment_mastercard, or payment_paypal) via AJAX. The server calls EMSPayment::sendPaymentRequest(), which computes an HMAC signature over the order parameters and returns a redirect URL. inschrijven.php. The handler validates the HMAC signature to confirm the response is genuine, then reads chargetotal, txndatetime, and the approval code. approval_code == 'Y' and status == 'APPROVED', the booking is marked paid, the full transaction record is written to boeking_payment, and the PDF + email pipeline is triggered. Any other outcome transitions to PAYMENTFAILURE and the user sees a friendly error. The codebase also contains a legacy Ogone payment integration — the predecessor gateway. It is no longer active but was retained in the codebase as a reference, since some historical bookings were made through it and the response format is still documented in comments.
Every confirmed registration produces a personalised, print-ready PDF race ticket. The ticket is generated server-side using PDFlib, a commercial PDF creation library chosen for its precise layout control and reliable font rendering.
The ticket factory (framework/factories/PDFTicket.php) composes the document programmatically — there is no template file, no HTML-to-PDF conversion, and no intermediate render step. Every element is placed with exact point coordinates on an A4 canvas (595 × 842 pt).
A full-width branded image (pdf_ticketkop.jpg) placed at the top of the page establishes the race identity and year.
A distance-specific map image (pdf_10KM.jpg, pdf_15KM.jpg, pdf_25KM.jpg) is loaded at runtime based on the booked event, giving each ticket a unique visual signature.
Name, address, postcode, city, and selected distance are typeset in Helvetica-Bold. The race date (28-02-2027) and distance-specific start time (13:00 / 12:30 / 11:45) are printed in large type.
The 8-character access key is printed prominently for start-line verification. The booking number is reversed out in white on the dark footer background using Courier so it is easily scanned by race staff.
// 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(""); The finished PDF is saved to pdf_gen_tickets/{BookingNumber}.pdf on disk, where it is immediately available for attachment to the confirmation email.
PDFlib was chosen over HTML-to-PDF converters like wkhtmltopdf because it offers deterministic pixel-perfect layout at the cost of needing a commercial license. For a ticketing system where a misaligned element could confuse race staff, that trade-off is worthwhile.
Confirmation emails are dispatched the moment a payment is verified — no queue, no delay. The PostOffice factory wraps PHPMailer and routes outbound mail through Mailgun's SMTP relay for reliable deliverability.
framework/mail_templates/reserveringsbevestiging.html — a standalone HTML file that can be edited by non-developers without touching PHP. [FIRSTNAME], [RESERVATIONNUMBER], and [BOOKINGDATA] are replaced at runtime with participant-specific values. The booking summary table is rendered as inline HTML. pdf_gen_tickets/{BookingNumber}.pdf) is attached before sending. Participants receive their ticket within seconds of payment confirmation. smtp.eu.mailgun.org:25. A silent BCC is copied to the event organisers for their own records. The subject line embeds the booking number: 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(); The 2025 redesign replaced a dated layout with a modern, mobile-first design system — while keeping the underlying PHP untouched where possible. The design language deliberately mirrors the rugged outdoors character of the event.
A single :root block defines the entire colour palette and spacing scale. Swapping brand colours for a future year requires one edit, not a find-and-replace across 2000 lines.
Breakpoints at 767 px and 1023 px. The navigation collapses to a hamburger menu on mobile; race cards reflow from a multi-column grid to a single column without any layout JavaScript.
Hard edges throughout. Every card, button, and input has square corners to echo the industrial feel of race bibs, trail markers, and terrain maps. A deliberate design constraint, not an oversight.
Each race distance has a dedicated GPX route rendered on an OpenStreetMap tile layer via Leaflet. Custom polyline styling matches the brand green (#99c038) and animated markers indicate key waypoints.
The RegisterPage JavaScript class sends an addressupdate event to the server on every field change. Server-side validation errors are returned as JSON and displayed inline — no full page reload, no lost data.
A full-viewport image slider on the home page cycles through race photography. Touch-swipe support works out of the box on mobile without additional configuration.
CSS is authored in css/design.css (1752 lines) and minified to design.min.css for production. JavaScript for the registration form is minified from js/inschrijven.js to inschrijven.min.js. The .htaccess applies gzip compression for CSS, JS, and font files and sets aggressive cache headers for static assets.
The production hosting environment is constrained to PHP 5.6 — yet the codebase must also pass a PHP 8 syntax check and run cleanly under PHP 8 on the development machine. This is the single trickiest constraint on the project.
The 2025 work involved auditing every PHP file for features that behave differently across the version gap:
mysql_query(), mysql_fetch_array(), and related calls were replaced with their mysqli_* equivalents, which are available in both PHP 5.6 and PHP 8. Iterator interface into Iterator and IteratorAggregate with stricter return-type enforcement. Custom collection classes were updated to implement the correct interface and return type declarations were added where PHP 5.6 ignores them harmlessly. $str{0}). All occurrences were converted to bracket syntax ($str[0]), which is valid on both versions. Writing PHP that runs on both 5.6 and 8 is harder than targeting either one alone. The strategy was: use only the subset of PHP that both versions share, and avoid every feature introduced after 5.6 that PHP 8 later changed behaviour for.
Every component chosen for practical reasons — no framework for its own sake, no dependency that does not pull its weight.
| Layer | Technology | Why |
|---|---|---|
| Server language | PHP 5.6 / 8 | Hosting constraint; no choice possible |
| Database | MySQL (mysqli) | Available on host; sufficient for the data volume |
| Framework | Custom (NSWebpage / MVC) | No Laravel / Symfony on PHP 5.6 without workarounds |
| PDF generation | PDFlib (commercial) | Pixel-perfect layout; deterministic output; font embedding |
| Email delivery | PHPMailer + Mailgun SMTP | Reliable deliverability; BCC audit trail to organisers |
| Payment gateway | EMS e-Commerce (HMAC) | Supports iDEAL, Mastercard, PayPal in a single integration |
| Maps | Leaflet.js + OpenStreetMap | Open source; no API key required; GPX overlay trivial |
| DOM manipulation | jQuery 1.11.1 | Stable; works on the old-browser range event participants use |
| Hero slider | Jssor | Touch-swipe out of the box; zero server dependency |
| CSS architecture | Custom + CSS custom properties | No preprocessor needed; custom properties supported natively |
| Device detection | Mobile_Detect.php | Server-side breakpoint hint in addition to media queries |
What this project demonstrates beyond the feature list.
The PHP 5.6 constraint forced a custom framework that is small, fully understood, and easy to audit. No hidden magic, no version incompatibilities buried in vendor code.
The dirty-flag pattern in NSPersistentObject is a legitimate pattern used in many large frameworks. Sometimes reinventing a small wheel beats pulling in a large one.
Storing the page singleton in $_SESSION and syncing on every field change means participant data is safe even if the browser crashes mid-form. Hidden inputs would not survive a tab refresh.
HTML-to-PDF converters are convenient but produce variable output. For a ticketing system where layout accuracy matters for operational use, a proper PDF library was worth the licence fee.
The no-border-radius rule started as a time-saving shortcut and became a deliberate brand choice. Hard edges feel appropriate for a race that crosses dunes, forest, and beach.
Writing for PHP 5.6 and PHP 8 simultaneously is uncommon but tractable. The key is knowing exactly which features to avoid and documenting the constraint clearly for future contributors.
More Work
TST Terschelling
TransportMaster
TransportMaster