Transporter Mobile Application
Transportservice Terschelling
Kiewiet Rijwielverhuur BV
On the car-free Dutch island of Ameland, nearly every tourist arrives by ferry and immediately needs a bicycle. Kiewiet Cyclisme is the island's most established rental shop — and behind their website sits a purpose-built platform that handles every layer of the business: online reservations, luggage transport from the ferry dock, retail sales, agent partnerships, payment processing, invoice generation, and a fleet of desktop terminals on the shop floor. This is the story of that system.
Most booking platforms solve one problem. Kiewiet needed four solved simultaneously and tied together into a single coherent experience.
All four flows funnel into the same cart, the same payment gateway, the same order numbering system, and the same invoice engine.
The platform is built on PHP 8.4 with no external framework — no Laravel, no Symfony. Every layer from routing to ORM was designed and written specifically for this domain.
Every public URL enters through a single index.php entrypoint. Apache's .htaccess rewrites all non-file requests to it. From there, a custom router (NSSwitchBoard) parses up to six URI path levels, matches the first segment against a routing table, and loads the appropriate page controller. Unrecognised routes receive a 400 Bad Request and redirect to home — no generic error pages leaking stack traces.
Each page controller is a class that extends a shared base (CyclismeWebPage) and is stored as a singleton inside the PHP session. This means the entire page state — the shopping cart, selected dates, active product filters, agent login status — persists across requests without a single database read for UI state. A customer can browse for ten minutes, add bikes from multiple categories, then proceed to payment, and the server reconstructs the exact state of their session from memory on every request.
Database models follow a two-file pattern. A _base.php file is auto-generated from the database schema and contains nothing but typed properties and getter/setter boilerplate — it is never hand-edited. A companion file adds business logic on top. This separation means schema changes regenerate cleanly without touching custom code.
The base ORM class (NSPersistentObject) implements dirty-state tracking through a dual mechanism: an explicit dirty flag that code sets manually, and a background MD5 checksum of all serialised properties taken at load time. If either the flag is set or the checksum has changed, a store() call will persist. This prevents accidental double-writes and makes partial updates safe.
Store logic determines INSERT versus UPDATE at runtime by checking whether the record's UUID already exists in the database. There are no separate create and update methods — the ORM decides. Linked model collections (e.g., the line items inside a reservation) are attached to parent objects and saved in a single coordinated call.
A lightweight query builder (DataStore) wraps the ORM with positional parameterisation. Queries look like query("Groep_uuid=:1 AND Verhuur>0", $groupUUID). The builder resolves types automatically — integers pass through unescaped, strings go through the database escape function, arrays expand into SQL IN (...) lists, and custom date/time types serialise to MySQL format. The result is an EntitySelection — a lazy or eager iterator over fully-loaded model objects.
EntitySelection also supports in-memory set operations: intersection, union, and difference of result sets using ID lists. This allows combining multiple queries without additional round-trips.
Rental availability is not a simple stock counter. A bike reserved from Tuesday to Friday is unavailable for any overlapping period — but a bike returned Monday morning may be available Monday afternoon if turnaround time allows. Producten::buildAvailabilityMatrix() calculates a per-product, per-day availability grid across the entire requested date range, factoring in all existing reservations. This matrix powers the real-time calendar UI on the booking page.
Prices depend on the product group, the number of rental days, and the time of year. A separate pricing model (Prijzen) stores rules keyed by group, duration range, and date range. The engine resolves the applicable price for every cart item at checkout. Agent accounts receive a separate price group, so the same bike may carry a different rate depending on whether it is booked directly or through a partner agency.
Promotional discount codes are validated in real time during the booking flow, with rules for expiry, minimum order value, and applicable product categories. The discount recalculates the cart total and is written into the reservation record and invoice.
The luggage transport flow is constrained by the Wagenborg ferry timetable. Customers select a ferry crossing, and the system validates that the requested transport time is compatible with that sailing. The ferry schedule is fetched and upserted from an external API by a dedicated factory class (FerryScheduleFactory) and stored locally for fast lookups.
The platform integrates two payment gateways: OGone (Ingenico) and EMS. Customers choose their preferred method at checkout. After the gateway callback, Reservering::ValidateReservation() confirms payment status, generates the order number, writes the reservation to the database, generates a PDF confirmation, and dispatches a confirmation email — all in a single atomic post-payment sequence.
Order numbers are generated by a factory that maintains counters per order type (reservations, shop sales, luggage orders, invoices) with automatic resets on year and month boundaries. Numbers are zero-padded to a fixed width for consistent formatting across all documents.
The platform generates PDF documents natively for three document types:
A PostOffice class wraps PHPMailer to dispatch these as email attachments alongside templated HTML confirmation messages.
A separate REST API endpoint (ApiEntrypoint.php) serves a mobile scanning application used by transport staff on the island. Authenticated via an X-Auth-Token header, it exposes endpoints for loading parcel data, submitting delivery scans, and updating parcel location. Each scan event updates the luggage record's status in real time and is visible to customers checking their order.
The web platform does not operate in isolation. Running across approximately twenty macOS workstations in Kiewiet's shop locations on Ameland is a parallel desktop application built in 4D v20R8 — a full relational application platform with its own UI, data layer, and business logic.
The desktop app manages the complete operational side of the business: staff check customers in and hand over bikes using rental vouchers (VerhuurBonnen), process walk-in repairs, manage workshop parts inventory, run the point-of-sale cash register, and plan luggage transport routes.
Both systems share a MySQL database, but they do not simply share a connection. Changes flow through a dedicated sync layer built on top of a RecordSync table that acts as a change journal.
When the PHP web shop creates or modifies a record — a new reservation, a payment confirmation, a new customer — it writes an entry to RecordSync with a sequence number and the application identifier "Online". A background process in the 4D application polls for these changes via bridge.php, fetches the modified records, and upserts them into the 4D datastore using ORDA (Object Relational Data Access). The sequence number advances only after successful processing, ensuring no change is missed.
The reverse path works through 4D's trigger system. All 62 database tables have triggers that fire on every save and delegate to a central DataBridge orchestrator. This queues the change as a RecordSync entry, and the background process pushes it to bridge.php, which upserts it into MySQL. On HTTP 200, the sync entries are dropped.
The trigger architecture uses SharedStorage for inter-process communication inside the 4D runtime, and a deduplication mechanism prevents the same record change from being queued multiple times during rapid consecutive saves.
Luggage transport planning in the desktop app includes a route optimisation engine. A LuggageRouting class assigns parcels to transport runs, and an OpenRouteService integration queries the ORS API to calculate road distances. A Routing class implements a 2-opt improvement algorithm to minimise total route length across all intermediate stops (tussenstops).
The design system is built on 32 SCSS partials organised from design tokens through component layers to page-specific layouts. Custom properties carry the full token set — colours, spacing, typography — so design changes propagate consistently. The compiled stylesheet runs to approximately 49 KB.
Interactive behaviour is handled by 26 JavaScript modules covering the booking calendar, cart drawer, product filtering, luggage time selection, agent workflows, and payment flows. The calendar integrates FullCalendar.js with custom availability rendering driven by data from the PHP availability matrix. Moment.js handles all date arithmetic on the client side, kept in sync with server-side date validation.
AJAX communication between the frontend and backend uses a structured response format: every response carries a status, an optional redirect URL, an array of HTML fragments with CSS selector targets for jQuery-driven DOM injection, and a field-data array for form population. This makes the client code uniform — it handles one response shape regardless of which server action was called.
A separate admin backend (office.php) provides a full management interface for the business: browsing and editing all data models, managing pricing and inventory, viewing reservations and payments, and generating business reports. The office uses 65+ JSON schema files — one per table — that define field display names, data types, sort behaviour, and list visibility. These drive a dynamic list and form rendering engine, so adding a new field to the database propagates automatically to the admin interface without template changes.
Kiewiet is not a website with a booking widget. It is a complete operational platform built to spec for a real, seasonally intensive business where the margin for error is low — a double-booked bike or a missed luggage delivery affects a family's holiday. Every layer, from the routing table to the sync engine to the route optimiser, was designed and built to serve that reality.
The technical decisions — a custom ORM with dirty-state checksums, a session-persisted page object graph, a bidirectional change journal between two heterogeneous runtimes — were not made for elegance. They were made because the business demanded exactly this behaviour and no off-the-shelf solution offered it.
More Work
Transportservice Terschelling
TransportMaster
TransportMaster