August 1, 2026
Most of what I write here is about product. This one is about the code.
I founded Koppa in 2018 to build fantasy sports games — Fantasy Football and Fantasy Cycling, plus white-label versions for clients who want their own branded pool. It has since signed up more than 150,000 registered users across 15+ countries. I designed the architecture, wrote most of the code, and I still operate it.
That last part matters. Running your own software for years is the fastest way to find out which of your architectural opinions were correct and which were just fashionable. Here’s the honest version.
Koppa isn’t one application. It’s four, and they have deliberately different jobs.
| Component | Stack | Job |
|---|---|---|
| API | Laravel, PHP 8 | The domain. Scoring, users, teams, predictions, payments. |
| Web app | CakePHP | The logged-in game experience on the web. |
| Mobile app | Flutter | iOS and Android, one codebase. |
| Marketing site | Hugo, static | Public pages, SEO, conversion. |
Two questions usually come up here.
Why is the marketing site separate? Because it has no business being in the application. It’s static, it’s cached at the edge, it survives an outage of everything else, and marketing can change it without a deploy of the game. Static site generators are the highest return-on-effort decision on this whole list.
Why is the web app CakePHP when the API is Laravel? Honesty: history. The web app predates the current API and grew out of the older platform. Rewriting a working, revenue-generating front-end into Laravel would have cost months and delivered exactly zero new user value. So I put the domain logic behind an API, drew a hard line at that boundary, and let the web app age gracefully on the other side of it.
That’s the kind of decision that looks bad on an architecture diagram and good on a P&L. If you’re hiring someone to make technical calls, ask them about the ugly compromises they’ve kept, not just the clean ones they’ve made.
Every endpoint lives under /api/v1/. Not because I expected a v2 soon, but because mobile apps are the one client you cannot force to upgrade. Someone is running a build from eleven months ago right now, and the App Store review queue means you can’t hotfix your way out of a breaking change.
Versioning costs almost nothing on day one. Retrofitting it costs a quarter.
Fantasy sports have a specific shape: nothing happens for six days, then everything happens at once. A match ends, and suddenly every user team that contains a player from that match needs recalculating, plus league standings, plus notifications.
None of that belongs in a web request. It runs as queued jobs — dedicated jobs for user team recalculation, dispatched in batches, with retries and backoff. Redis backs the queue and Horizon gives me visibility into what’s actually running, which matters enormously at 22:00 on a Sunday when you want to know whether scoring is progressing or stuck.
The general rule I’ve settled on: if a user isn’t waiting for it, it’s a job. Imports, exports, scoring, emails, push notifications, cache warming. The request cycle should do as little as possible.
Alongside that sit Artisan console commands on a schedule for the recurring domain work — fetching fixtures, opening and closing prediction windows, rolling over game weeks.
Deploys happen while people are playing. So migrations are additive first:
It’s five deploys instead of one. It’s also zero downtime and a safe rollback at every step. On a system with a hard weekly traffic peak, that trade is not close.
Clients get the same platform under their own brand, domain and theming. The white-label versions run from a single codebase with per-tenant configuration, theming and feature flags — one deployment pipeline, many products.
The rule I enforce ruthlessly: no client gets a fork. The moment you branch the codebase for one customer, you own two codebases forever, and the second one never gets the security patches. Everything a client wants becomes configuration, a feature flag, or a feature everyone gets.
One codebase, iOS and Android. Structured feature-first: features/fixtures, features/predictions, features/home, each owning its own data, providers and screens, with a shared/widgets library for anything used more than twice.
The alternative — folders named models/, screens/, services/ with every feature’s files mixed together — works fine until the app has thirty screens, at which point every change becomes archaeology. Feature-first means a feature can be understood, rewritten or deleted in one place.
Two decisions that have paid off repeatedly:
Typed DTOs for every API response. The app doesn’t pass raw maps around. Every response deserialises into a typed object at the boundary. When the API changes shape, the build breaks on my machine instead of the app breaking on a user’s phone.
One person owns both sides of the contract. Because I write the API and the app, the endpoints are designed for the screens that consume them. When a screen needs four calls, I merge them into one server-side rather than making the app orchestrate. Teams split across an API team and an app team almost never get to do this — the negotiation cost is too high, so the app absorbs the complexity instead.
That’s the real argument for full-stack ownership, and it’s not about heroics. It’s that the cheapest place to fix an integration problem is on whichever side of the boundary is cheaper, and you can only make that call if you own both.
Everything runs on Kubernetes on Google Cloud, built and deployed through GitLab CI/CD, with production manifests versioned in the repository alongside the code.
The cluster runs separate deployments for the web processes, the queue workers and the scheduler. They scale independently, which is the entire point: on a match night I need many more queue workers and roughly the same number of web pods. Coupling them would mean paying for idle web capacity to get scoring throughput.
Observability is Sentry for errors, Grafana for metrics, and alerting that wakes me up. If you run your own product, you learn quickly that unmonitored software isn’t in production — it’s just deployed.
Do you need Kubernetes? Probably not. Most products would be better served by a managed platform, and I’d tell most clients so. I run it here because I have several applications, several environments, sharply spiky workloads and white-label deployments to manage, and because operating it myself keeps the skill current. If you have one app and flat traffic, a PaaS will make you faster and I will happily set that up instead.
Being able to say “you don’t need this” is a large part of what you’re paying a technical lead for.
Because I don’t think those are two jobs.
I’ve spent fifteen years as a product owner and product manager — ID&T, ANWB, Essent, Coinmerce, Fox Sports. And the whole time I’ve been building and running this. The two feed each other: I estimate better because I’ve built it, and I build the right thing more often because I’ve had to own the roadmap.
If that combination is useful to you — as a Laravel developer, a full-stack developer, or someone to own the technical side of your product — have a look at my availability and get in touch.