All work WEB PLATFORM / TRAVEL OPERATIONS live

22  /  WEB PLATFORM / TRAVEL OPERATIONS

Yatigo — DMC Operations Platform

Each transfer booking arrives once per leg, and an import that reported success silently dropped one.

April – July 2026Laravel 12PHP 8.2+Blade + Alpine.jsTailwind CSSMySQLLaravel SanctumLaravel ReverbExpo Push

The challenge

I found inbound travel operations running on spreadsheets. Partners sent a daily transfer list with arrivals, departures and hotel-to-hotel legs stacked in one sheet, and getting that file into the dispatch board was a manual step outside the system. Subcontracted transport suppliers had no access to the system at all. Drivers had a mobile app that could pull today's jobs, but nothing pushed a change to them: no notification, no realtime, and no web fallback for a driver without the app.

What I built

I built a Laravel 12 operations monolith: one database, four surfaces. Dispatchers work a drag-and-drop scheduler and a fleet timeline, subcontracted suppliers sign in to a scoped version of the same app, and drivers get a mobile-first web portal alongside a token API for the React Native app. I wrote a spreadsheet pipeline that fuzzy-matches arbitrary partner column headers, reads multi-section files section by section, and runs the import as a queued job with a per-row audit trail and a failed-rows CSV. It serves the same travel company as the quoting copilot elsewhere in this portfolio, and shares no code or stack with it.

How it works

AUTOMATED THE GUARANTEE Partner sheet arrival, departure and hotel-to-hotel blocks Section-aware reader each block, its own header each row, its banner date Queued import per-row audit trail failed rows return as CSV The upsert key reservation number + route re-imports stay idempotent Arrival leg same reservation number Departure leg same reservation number Before: one unique index on the reservation number collapsed the two legs into one row the later leg silently overwrote the earlier, and the importer still reported every row as succeeded
The mechanism, drawn from the build.

What it looks like

Yatigo — DMC Operations Platform — screenshot
Captured liveWEB PLATFORM / TRAVEL OPERATIONS

The detail

01

Section-aware import

Real partner exports are not tables. One sheet stacks an arrival block, a departure block and a hotel-to-hotel block, each behind a banner row, each re-emitting its own differently ordered header, with Excel time serials anchored at 1899 and passenger counts written as "2 + 0". I wrote the reader to detect the densest header row, return the union of every section's columns so none are dropped silently, adopt each section's own header for the rows beneath it, and tag every row with the date printed on its banner, which is what finally stopped imported transfers landing on the import date instead of the date in the file.

02

A booking is not a job

The transfers table carried a unique index on the reservation number. But a reservation number is a booking reference, and the same booking appears once per leg, so importing a multi-leg file collapsed an arrival and a departure into one row, the later leg silently overwriting the earlier while the importer still reported every row as succeeded. I caught it from the outside, by uploading a seventeen-row file and counting what came back. I now key the upsert on reservation number plus route, so legs coexist and re-imports stay idempotent.

03

Tenancy as global scopes

The opening ask was a separate database per supplier. What I shipped is row-level isolation in one database, written as three Eloquent global scopes with different rules: owned resources such as drivers and coaches scope on tenant id, transfers scope on the assigned supplier because a delegated job stays the DMC's, and the partner directory narrows a supplier to its own row so dropdowns stop leaking the roster. Because they are global scopes, every existing query site inherited the rule without my rewriting it, and nine feature tests hold it in place.

04

One trigger, every channel

There were two ways work reached a supplier and only one of them was visible. An explicit delegation notified; picking that supplier's driver off the scheduler notified nobody. I put a saving hook on the transfer observer that stamps the supplier whenever their coach or driver is assigned, and that same observer fires the in-app notification, the Expo push and the Reverb websocket event, so the channels cannot drift. I snapshot the broadcast payload as a plain array at dispatch time rather than serialising the model, so the queue worker never re-queries under a stale global scope.

05

Written for its maintainers

I started this repo with context rather than a feature: a CLAUDE.md and thirteen domain files covering transfers, fleet, tenancy, import and export, the mobile API and the rest. Each carries a gotchas section recording the actual scars, including a soft-delete-plus-unique-constraint collision in the seeder and a pluck call that reads a dot in a column header as nested access.

The outcome

It is live in production for transfer dispatch on the company's own domain, with TLS, a queue worker and a websocket server running as managed services. Real partner files import against the production database, and I last confirmed the worker and websocket server active with no pending and no failed jobs. I measured nothing in hours saved or headcount, so I claim nothing there. Yatigo is the name on the public product page; the operations app behind the login still carries the earlier Travel Software branding.

Running in production. The link is held back pending the client's sign-off, since the same domain fronts their live operations system.