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.
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 machineFive 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 builderAdmins 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 messagesNotifications and messages are pushed over WebSockets (Laravel Reverb) on private channels per role, clinic and user.
▸An audit trail on every recordEvery create, update, delete and restore is recorded by an observer and shown as a history beside the record.
▸Reminders that chase stalled cyclesA daily scheduled job finds treatments that have stopped moving and sends reminders, then escalations, using each clinic’s own settings.
▸Integrations behind interfacesFinance 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.
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 cycleA treatment cycle and its timeline on the patient record.outcome analyticsClinic-level outcome analytics.patient portalThe 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
AWS · one UK regionpushdeployapplypullrunSQLA
selectedone 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?