Programming

TrailRun Website Redesign

MooiWeer

Client
MooiWeer
Category
Programming
Year
2025
Technologies
PHPJavascriptMySQLPDFLib
TrailRun Terschelling

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.

3Race Distances
~22kLines of PHP
PDFlibTicket Engine
iDEALPrimary Payment
2025Redesign Year

What is this project?

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.

PHP 5.6 / 8MySQLPDFlibPHPMailerMailgun SMTPEMS Payment GatewayiDEALjQueryLeaflet.jsCSS Custom Properties

Custom MVC Framework

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.

NSWebpage — Base Page Shell

Renders the HTML document skeleton, injects SEO meta tags, loads CSS/JS assets, and delegates content to subclasses through an abstract drawPage() method.

NSActiveWebpage — AJAX Dispatcher

Extends NSWebpage with session management and an event-driven AJAX layer. Incoming requests are parsed into NSEvent objects and routed through a handleEvent() dispatcher.

NSPersistentObject — Lightweight ORM

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.

NSDatabase — Singleton Connection

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.

Page classes

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

The Booking System

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.

Payment flow

01Distance Select10 / 15 / 25 km
02Participant DetailsAJAX addressupdate
03Method SelectiDEAL / MC / PayPal
04EMS GatewayRedirect out
05CallbackPOST → inschrijven.php
06PDF + EmailAuto-generated

Data model

The booking is decomposed into three related entities, keeping concerns cleanly separated:

ModelRoleKey fields
BoekingPrimary booking record, participant info, payment stateboekingsnummer, accesskey, state, email, birthdate
BoekingElementIndividual line items (one per distance/slot selected)event_id, price, discount, quantity
BoekingPaymentImmutable payment transaction logpaymentid, amount, currency, brand, status, ipaddress
EventRace configuration loaded from databaseeventname, adultprice, slots, minimumage, active

State machine

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

Slot management

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.

Payment Gateway Integration

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.

  1. 1
    Order creation When the participant clicks "Pay", the client sends a payment event (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.
  2. 2
    Gateway redirect The browser is redirected to the EMS-hosted payment page. All sensitive card handling happens on the gateway's domain — the application never touches card numbers.
  3. 3
    Asynchronous callback After authorisation, EMS POSTs the result to inschrijven.php. The handler validates the HMAC signature to confirm the response is genuine, then reads chargetotal, txndatetime, and the approval code.
  4. 4
    Finalisation On 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.

PDF Ticket Generation with PDFlib

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).

What goes on the ticket

Header image

A full-width branded image (pdf_ticketkop.jpg) placed at the top of the page establishes the race identity and year.

Route map

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.

Participant block

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.

Access key + booking number

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.

How the build works

// 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.

Transactional Email Pipeline

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.

  1. 1
    Template load The HTML email template is read from framework/mail_templates/reserveringsbevestiging.html — a standalone HTML file that can be edited by non-developers without touching PHP.
  2. 2
    Token substitution Placeholder tokens such as [FIRSTNAME], [RESERVATIONNUMBER], and [BOOKINGDATA] are replaced at runtime with participant-specific values. The booking summary table is rendered as inline HTML.
  3. 3
    PDF attachment The freshly generated ticket PDF (pdf_gen_tickets/{BookingNumber}.pdf) is attached before sending. Participants receive their ticket within seconds of payment confirmation.
  4. 4
    SMTP delivery PHPMailer sends to the participant's address via 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();

Front End & 2025 Redesign

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.

CSS Custom Properties

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.

Mobile-first flexbox

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.

No border-radius — intentionally

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.

Leaflet.js route maps

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.

AJAX registration form

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.

Jssor hero slider

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.

Build artefacts

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.

PHP Compatibility Challenge

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:

  1. 1
    mysqli_* over mysql_* All legacy 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.
  2. 2
    Iterator interface PHP 8 splits the old 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.
  3. 3
    String indexing syntax PHP 8 deprecates curly-brace string indexing ($str{0}). All occurrences were converted to bracket syntax ($str[0]), which is valid on both versions.
  4. 4
    No type hints on parameters PHP 5.6 supports neither scalar type hints nor return types in function signatures. All code was written without these, relying on docblock comments for IDE support instead.

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.

Full Tech Stack

Every component chosen for practical reasons — no framework for its own sake, no dependency that does not pull its weight.

LayerTechnologyWhy
Server languagePHP 5.6 / 8Hosting constraint; no choice possible
DatabaseMySQL (mysqli)Available on host; sufficient for the data volume
FrameworkCustom (NSWebpage / MVC)No Laravel / Symfony on PHP 5.6 without workarounds
PDF generationPDFlib (commercial)Pixel-perfect layout; deterministic output; font embedding
Email deliveryPHPMailer + Mailgun SMTPReliable deliverability; BCC audit trail to organisers
Payment gatewayEMS e-Commerce (HMAC)Supports iDEAL, Mastercard, PayPal in a single integration
MapsLeaflet.js + OpenStreetMapOpen source; no API key required; GPX overlay trivial
DOM manipulationjQuery 1.11.1Stable; works on the old-browser range event participants use
Hero sliderJssorTouch-swipe out of the box; zero server dependency
CSS architectureCustom + CSS custom propertiesNo preprocessor needed; custom properties supported natively
Device detectionMobile_Detect.phpServer-side breakpoint hint in addition to media queries

Lessons & Takeaways

What this project demonstrates beyond the feature list.

Constraints drive good design

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.

Homegrown ORM ≠ bad ORM

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.

AJAX state sync beats hidden fields

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.

PDF generation is not a commodity

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.

Design constraints as brand decisions

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.

Dual-version PHP is solvable

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.

Project Images

More Work

Related Projects

Start a project with us

Have something in mind? Let's talk about how we can help.