Building Koppa: Laravel, Flutter and Kubernetes at Scale

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.

The Shape of the System

Koppa isn’t one application. It’s four, and they have deliberately different jobs.

ComponentStackJob
APILaravel, PHP 8The domain. Scoring, users, teams, predictions, payments.
Web appCakePHPThe logged-in game experience on the web.
Mobile appFlutteriOS and Android, one codebase.
Marketing siteHugo, staticPublic 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.

The Laravel API

Versioned from day one

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.

The scoring problem

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.

Migrations that don’t take the site down

Deploys happen while people are playing. So migrations are additive first:

  1. Add the new column, nullable. Deploy.
  2. Backfill it in a job. Deploy.
  3. Start writing to it, still reading the old one. Deploy.
  4. Switch reads. Deploy.
  5. Drop the old column — weeks later, when you’re sure.

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.

Multi-tenancy for white-label

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.

The Flutter App

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.

Kubernetes, and Whether You Need It

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.

What I’d Do Differently

  • I’d have added typed DTOs on the mobile side sooner. The first version passed maps around. Every field-name change was a runtime bug found by a user.
  • I’d have written the scoring tests first. Scoring is the one place where a subtle bug is both invisible and catastrophic — nobody notices for a week, and then everybody notices at once.
  • I’d have introduced feature flags earlier. I spent too long on long-lived branches before accepting that flags plus trunk-based development is simply less painful.
  • I’d have set the observability up before I needed it. I added proper monitoring after an incident, like everyone does, and it cost more than doing it up front would have.

Why This Is on a Product Manager’s Website

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.