Thapar CarPool
Completed
Product · Mobile

Thapar CarPool

Backend / API · 2023

Ride-sharing for students travelling between the Patiala campus and nearby cities, shipped as a Flutter client against api.thapargo.tech. The schema splits the intent in two — a plain city hop, and a ride recorded against a train or flight number so people arriving on the same service could find each other rather than merely the same city, though that read side never landed as an HTTP route — and the API that serves it is a hand-rolled PHP MVC service written for shared hosting with no Composer and no shell: its own autoloader, router, sanitising and logging middleware, and a PDO layer where every column declares a type and a failed insert returns the whole list of problems instead of throwing on the first. A Create React App web front end covering the same six screens settled the information architecture and the two booking widgets, then was archived without ever calling the API.

Built with
PHPPHP
MySQLMySQL
FlutterFlutter
DartDart
FirebaseFirebase
ReactReact
JavaScriptJavaScript
Project Details

STATUS
Completed
ROLE

Backend / API

YEAR

2023

TYPE

Product · Mobile

TAGS
Mobile
Carpooling
Transport
Campus

Students heading out of Patiala coordinate in WhatsApp groups. Somebody posts a train number and a departure time and hopes another person on the same service scrolls past it before they book a cab alone. Thapar CarPool turns that into a lookup, and the interesting half of the build is the API, which was written from scratch in PHP with no framework under it.

Writing the framework was not a stunt. The deploy target was shared PHP hosting: no Composer, no shell, no ability to install anything, a public_html directory and an FTP client. Under that constraint Laravel is not an option and the realistic alternative is a pile of scripts, each one re-opening its own PDO connection and re-inventing its own input checks. The middle path is to write the four pieces a framework actually gives you - autoloading, routing, input handling and a query layer - once, in about six files, and then have every endpoint after that be five lines.

Two kinds of ride

The schema splits the intent in two, which is the design decision the whole product rests on. wrap_go is the simple case: a date, a time band and a destination city. wrap_ride adds a mode and a mode number, meaning a train or flight number, so people arriving on the same service can be matched to each other rather than merely to the same city. Both tables hang off a user id and carry a status column, set to pending when the row is created.

That split is what makes matching tractable. Matching on "going to Delhi on Friday" produces a list too loose to act on; matching on train 12232 produces a list of people who are provably going to be in the same place at the same minute. Keeping them as separate tables rather than one nullable column means the looser case never has to pretend it has a service number.

One way in

.htaccess sends every request that is not an existing file or directory to index.php, and that file is the whole lifecycle in eleven lines. It pulls in the schema bootstrap, the router and the two middlewares; sanitisation runs before the router is allowed to look at anything; the router dispatches; then the request and the buffered response are both written to disk. Nothing can reach a controller without passing through the same three steps, which is the only structural guarantee a framework this small can offer.

Building the framework as well as the app

  • Autoloading via spl_autoload_register, translating namespace separators into directory separators relative to __DIR__ and failing loudly on a missing class.
  • A router holding separate GET and POST pattern tables, matching each registered pattern against the request URI with preg_match and spreading the captured groups into the handler as arguments.
  • Sanitising middleware that runs before the router touches anything, walking $_GET, $_POST and $_COOKIE recursively and pushing strip_tags over every leaf value.
  • Logging middleware appending the method, URI, headers and body of each request as a JSON line, then capturing the buffered response body and logging that too, which gives a replayable trace of every call the mobile client made.
  • A PDO wrapper where each insert and update declares a type per column from a fixed vocabulary (int, phone, email, string, unique, empty, date, gst), validated before the statement is built. Phone numbers go through a regex accepting ten digits, or eleven behind a leading zero; unique runs a lookup against the target table first.
  • Errors as data. Every validator returns a pair of a boolean and a message naming the column it failed on, and the handler collects them into an array. A bad insert returns a list of problems rather than throwing, so a controller can hand the whole list back to the client in one response instead of failing on the first field.
  • A debug switch. Flipping one static on the handler appends the table name, the submitted data, the datatype map, the field list and the generated SQL to that same error array, which is the entire debugging story on a host with no logs you can read.
  • Partial updates by omission. Update methods take every column as a nullable argument and build the SET clause from whatever was not null, so the client can patch one field.
  • Schema as code. There are no migrations. A bootstrap file runs CREATE DATABASE IF NOT EXISTS and CREATE TABLE IF NOT EXISTS for users, wrap_go and wrap_ride on startup, so the schema is whatever that file says it is.
  • Duplicate guards at the controller layer. Registration rejects an email already in use; creating a ride first looks for an identical row on the same user, date, time and place and refuses rather than silently doubling it.

Two smaller decisions are worth naming. Prepared statements are real prepared statements - PDO::ATTR_EMULATE_PREPARES is switched off - and every value additionally goes through htmlspecialchars on the way in, so the sanitising middleware, the escaping in the handler and the parameter binding are three independent layers doing overlapping work. And the query builder keeps a raw queryRunner escape hatch, so anything the builder cannot express is one call away rather than a reason to abandon the abstraction.

What the routes actually cover

Seven routes are registered, two of them health-check stubs that echo a string. The rest are POST /user/check/exists-by-email, POST /user/register, POST /user/update-hostel, POST /wrapgo/add and POST /wrapride/add. Every one of them writes.

The read side never landed as HTTP. The models carry getAllWrapGos, getWrapGoById, getAllUsers, update and delete, all of them working, and none of them are exposed by a route. So the committed API can record that four people are on train 12232 and cannot yet tell any of them about the other three. The users table also carries an auth_token column that no committed route reads or writes.

The clients

A Flutter app talking to api.thapargo.tech. Google sign-in over Firebase Auth, an OTP entry screen, and an auth token cached in shared_preferences alongside the user's name, photo and hostel, refreshed through a dedicated update endpoint so a returning user skips the login screen. Screens cover the two ride types, bookings, profile, hostel change, payments, rewards, donations, FAQ and settings, with every API response surfaced through toast messages driven by a status boolean the server returns on every route.

The client is also the evidence that the deployed API ran ahead of the committed one: it calls /user/login and /user/update, neither of which is in the repository, and expects back a user object carrying the auth_token the schema has a column for. What is on disk is the framework and the write path, not the last version that was live.

Alongside it, a Create React App front end for thapargo.tech covered the same six screens the Flutter client shipped: the landing surface, the two booking flows, bookings, a ticket view and profile. Six react-router routes, no layout chrome shared between them, each screen a self-contained component with its own stylesheet and a maroon accent on a light ground. The constraint that shaped every one of them is that this is a phone product rendered in a desktop browser. There is no responsive breakpoint anywhere in the stylesheets and no attempt at one - every screen is a fixed-width column of cards centred on the page, because the target was a web view wrapped as an app and the layout only ever had to work at one size. That decision buys a lot: the ticket view can be laid out like a cinema m-ticket, the booking flows look identical on Android and iOS, and nothing has to reflow.

Two widgets on those screens took all the actual work, and both are decisions rather than layout. The dropdown is hand-rolled rather than a native <select>, because a <select> renders as a system sheet on iOS and an in-page list on Android and the two look nothing alike; the commented-out <select> blocks are still sitting in Wrapgo.js next to their replacements. It keeps an isOpen boolean, rotates an inline SVG chevron through a CSS class and animates the list open, and its one oddity is that selecting an option sets isOpen to true rather than false - the menu closes only because the same click bubbles up to the wrapper's toggle handler. It works by accident rather than by design.

The date picker is the more interesting call. Rather than validate a date after the fact, it refuses to render one: a filterDate predicate greys out everything except the 9th, 10th and 11th of the month, the pilot's trip window hard-coded into the calendar, so an invalid booking cannot be expressed. Constraining the input instead of checking the output is the right instinct. Hard-coding the dates into the component rather than passing them as a prop is not, and it is why the screen would need editing rather than configuring for the next trip window. The web version was archived there, once the Flutter client became the shipping surface and it was clear two teams were building the same six screens against the same PHP API.

Honest state

The database credentials sit in a committed config class rather than an environment file. The sanitising middleware tries to rewrite php://input after cleaning it, which is not a writable stream, so a JSON body reaches the controller untouched while form-encoded input is cleaned - and every route here reads form-encoded input, which is why it never mattered. The WrapGo controller reads a $status variable its own signature does not declare, because the router passes it as a fifth argument to a four-argument method and PHP quietly drops it. And around 30 KB of captured request and response logs are committed alongside the code, which is a useful record of how the client actually called the server and not something that should have been in version control.

The web front end never got past being a front end. None of its screens call the API, the bookings list and the ticket contents and the fares are literals in JSX, and the dropdown's selection never leaves component state. The train-and-flight toggle on the matching screen holds its state in useState but neither button ever calls the setter, so the mode is fixed at whatever it initialises to. The bookings screen renders the illustrated empty state and the populated list at the same time, because there is no condition between them yet. The ticket hotlinks its poster and its QR square from third-party CDNs and carries a literal booking reference, train number and pickup hostel, and the profile is a placeholder identity with a stock photograph. Its stylesheet imports reach into ../../../node_modules/ by relative path instead of using the package specifier, which happens to work under Create React App and would break the moment the build tool changed. And there is no index route, so the application renders nothing at / and every entry point is a deep link.

Project Details

STATUS
Completed
ROLE

Backend / API

YEAR

2023

TYPE

Product · Mobile

TAGS
Mobile
Carpooling
Transport
Campus