← cd ~/work

case study · client work · health-tech · Assured Fertility

fertility-clinic-platform/

Assured Fertility’s management platform for fertility treatment across partner clinics, built alone from the first commit. Clinic staff record each treatment cycle as it happens, the provider’s team sees every clinic, patients follow their own progress, and everyone gets live notifications. I wrote the code, the infrastructure and the pipeline that ships it.

Nothing here identifies Assured Fertility’s clinics, staff or patients. The screenshots are from a local copy with sample data and neutral branding.

role sole developer code, infrastructure, CI/CD
stack Laravel 12 · Next.js 16 PHP 8.5 · React 19 · Postgres 16 · Reverb
scale ~150 API endpoints 3 portals · 36 screens · 5 treatment types
dates 2025–2026 first commit January 2025

1 · the problem

One record of every treatment cycle, shared by people who must not see each other’s patients.

Fertility treatment runs in cycles, and each kind has its own steps: IVF and ICSI, IUI, frozen embryo transfer, egg freezing and ovulation induction. The provider works with partner clinics and needed one system where clinic staff record each cycle as it happens, the provider’s team sees every clinic, and patients see their own progress.

The rules change by treatment type and by plan, and an outcome decides what happens next, so the data had to be right as well as shared. Clinics may only ever see their own patients, and every change has to be traceable.

2 · what I built

Three portals, one api, and the treatment path stored as data.

A Laravel 12 api serves three Next.js portals: the provider’s admins, clinic staff and patients. Policies scope every request to what the role may see, so a clinic only ever reaches its own patients. Some modules sit behind feature flags until the client switches them on.

  • Treatment cycles as a state machine Five treatment types, each with its own form and validation. The cycle screen is driven by a state machine, so staff only see the steps that are valid next.
  • A visual plan-flow builder Admins draw a treatment path on a canvas from start, form, decision, fork, next-cycle, birth and end nodes. The path is saved as data and drives the cycle screens.
  • Live notifications and messages Notifications and messages are pushed over WebSockets (Laravel Reverb) on private channels per role, clinic and user.
  • An audit trail on every record Every create, update, delete and restore is recorded by an observer and shown as a history beside the record.
  • Reminders that chase stalled cycles A daily scheduled job finds treatments that have stopped moving and sends reminders, then escalations, using each clinic’s own settings.
  • Integrations behind interfaces Finance applications go to a lender’s API and come back by webhook; video calls with transcripts work with three meeting providers behind one interface.

How a request travels

Select any box to see what it does. Everything sits behind one gateway, so the browser talks to a single origin.

architecture
selected api [LARAVEL] [PHP 8.5] [REST]

About 150 endpoints over JSON. Sessions are a signed JWT in an httpOnly cookie, checked by a custom guard, with login rate-limited. Twelve policies scope every request by role and clinic; an observer writes the audit trail.

$ php artisan route:list --path=api
treatment cycle A patient’s treatments tab: the treatment timeline of milestones, the current IVF treatment details and the cycles in its journey. Sample data only.
A treatment cycle and its timeline on the patient record.
outcome analytics Clinic outcome analytics: total treatments, success rate, average cycles and time to outcome, with success rate and cycle trends by treatment type. Sample data only.
Clinic-level outcome analytics.
patient portal The patient portal on a phone, showing the patient’s own treatment timeline. Sample data only.
The patient’s own view of their treatment, on a phone.

3 · infrastructure

AWS, written in Terraform, deployed from a git tag

Select any box. The whole estate is declared in Terraform with remote state, and CI reaches AWS through a short-lived role rather than stored keys.

infrastructure
selected one instance [EC2] [DOCKER COMPOSE]

One instance runs the whole app under Docker Compose. A deploy pulls the new images, restarts the containers, runs migrations and warms the config, route and event caches, then prunes old images. Multi-arch images are ready for an ARM (Graviton) instance.

$ docker compose pull && docker compose up -d

4 · decisions & trade-offs

Every choice had a cost. Here is what it was.

4.1 · One instance, not a cluster

chose
Docker Compose on one EC2 instance, managed Postgres
because
I first built it on ECS with a load balancer, NAT, a managed cache and separate staging. For this load that was mostly idle infrastructure, so I consolidated onto one instance and kept the database managed, where backups matter most.
cost
No redundancy at the app tier, and a deploy recreates containers in place: a short blip rather than zero downtime.

4.2 · The treatment path as data

chose
A plan flow drawn in a builder, read by a state machine
because
The steps differ by treatment type and plan. Storing the path as data means a new path is drawn, not coded, and the cycle screen can never offer a step the path does not allow.
cost
A state machine has to be tested hard: contract and snapshot tests cover every transition.

4.3 · Sessions in a cookie the browser cannot read

chose
A signed JWT in an httpOnly cookie, custom guard
because
Script on the page cannot read it, and the Next.js edge proxy can still route each role before anything renders.
cost
A guard of my own to maintain instead of a framework default.

4.4 · No stored cloud keys

chose
GitHub OIDC role plus Systems Manager
because
CI gets short-lived credentials scoped to this repository, deploys without SSH, and the server reads its config from one encrypted parameter.
cost
The deploy is a script sent through Systems Manager, which is harder to debug than one run by hand.

5 · testing & delivery

Nothing deploys until static analysis and the tests pass.

Releases are git tags. The pipeline runs Larastan and PHPUnit (in parallel, against a real Postgres 16) and only then builds the images, pushes them and deploys through Systems Manager. Pull requests run the same checks.

The front end has its own Vitest suite, with contract and snapshot tests over the cycle and plan-flow state machines. Static analysis started with 283 errors; I took it to zero and kept it there.

$ php artisan test --parallel && pnpm vitest run
backend tests
~608 in 81 files
front-end tests
~259 in 16 files
static analysis
Larastan level 5 · 0 errors
release trigger
git tag v*
deploy path
OIDC → ECR → SSM
dependency updates
Dependabot, weekly

6 · results

$ fertility-clinic-platform --facts
developer
one: me
tagged releases
47 (v0.1 → v1.5)
api endpoints
~150
screens
36 across 3 portals
treatment types
5
phpstan errors
283 → 0
cloud keys stored in CI
none
infrastructure
all in Terraform

Need a platform built, run and handed over by one person?

$ curl sala.dev/contact -d "re: fertility-clinic-platform"
NORMAL /work/fertility-clinic-platform available UK · remote tony@sala.dev updated
available Start a project →