← cd ~/work

case study · SaaS · my own product · studio booking

moveflow/

Moveflow is my own product: a booking platform for dance and fitness studios. Customers book classes, enrol on courses and buy packs or memberships; instructors see their rosters; the studio runs everything from one back office; and the front desk checks people in from a phone. I designed it, wrote all of it, and rewrote it once to get the foundations right.

role founder · sole developer product, design, code and infrastructure
stack Symfony 8.1 · SvelteKit PHP 8.5 · Doctrine · Postgres 16 · Stripe
scale ~200 API routes 22 domain areas · 55 entities · 69 screens
dates 2026 first cut in April · rewrite in July

1 · the problem

Studio booking is full of rules that are easy to get wrong with money attached.

A studio sells the same hour in many ways: a drop-in class, a place on a six-week course, a credit from a ten-class pack, or a membership that renews every month. Each comes with rules about who can book, how far ahead, how many people fit in the room, and what happens when someone cancels late or does not turn up.

Off-the-shelf tools make studios bend to their model. I wanted a platform where those rules live in tested domain code, payments cannot oversell a class, and the front desk keeps working when the studio Wi-Fi does not.

2 · what I built

One API, a web app for every role, and a check-in app for the front desk.

A Symfony 8.1 API holds the domain: 22 areas from scheduling and booking to billing, compliance and messaging, each behind repository and gateway ports. A SvelteKit app serves the public timetable, the customer portal, instructors and the admin back office, and a separate installable app handles front-desk check-in.

  • Booking that cannot oversell Booking locks the class row inside a transaction, checks eligibility (course sessions, the advance-booking window, room capacity, duplicates) and only then claims the spot, including guest spots for a party.
  • Packs, memberships and card payments Customers pay by card through Stripe Checkout, spend credits from a pack, or book on a membership. Subscriptions renew, pause, freeze and retry failed payments, with dunning for the ones that keep failing.
  • Cancellation rules as settings A free-cancellation window, late-cancellation and no-show fees are studio settings. Cancelling before the deadline refunds the booking; after it, the fee applies. Waitlists offer freed spots in order and expire unanswered offers.
  • Courses and standing reservations Multi-week courses enrol a customer onto every session at once. Members can hold the same slot every week; a nightly job books them into each new class and falls back to the waitlist when it is full.
  • A customer portal and a back office Customers manage bookings, packs, memberships, waivers and calendar feeds. Admins run the timetable, people, payments, discounts, gift cards, reports and instructor pay, and can book on a customer’s behalf.
  • Front-desk check-in that works offline An installable app for staff: pick a class, scan each member’s booking QR, or tap a name. The roster is cached and scans queue in IndexedDB when the connection drops, then sync when it returns.

One gateway, two front ends, one domain

Select any box to see what it does. Caddy gives each front end one origin, so the session cookie stays first-party and the browser never makes a cross-site call.

architecture
selected api [SYMFONY] [PHP] [DOCTRINE] [DDD]

Thin controllers call use-case services in 22 domain areas. Entities are final, statuses are enums, and every repository or gateway is an interface the domain owns, with Doctrine and Stripe adapters plugged in. Session auth with an admin > instructor > customer hierarchy. Console commands expire unpaid bookings, renew memberships, fulfil standing reservations and send reminders.

$ bin/console debug:router | wc -l
class booking The public Moveflow timetable for the week, with each class’s time, teacher, studio, spaces left and a book button with the price. Sample data only.
Booking a class from the public timetable.
customer portal A customer’s upcoming bookings, each with calendar, move and cancel actions and its free-cancellation deadline, plus a calendar subscription link. Sample data only.
A customer’s bookings, with moves and cancellations they can do themselves.
admin back office The Moveflow admin bookings list, with reference, class, time, status and a check-in action on each row. Sample data only.
Running the studio from the back office.

3 · infrastructure

The whole product in one Compose file

Select any box. Moveflow is not hosted yet: it runs as one Docker Compose stack, in a development and a production-style variant, and the quality gate is a git hook rather than a CI service.

infrastructure
selected api container [PHP] [SYMFONY]

On start the container installs dependencies, waits for Postgres, runs the Doctrine migrations and serves the API with PHP’s built-in server running several workers. Vendor files and cache live in named volumes, apart from the source mount.

4 · decisions & trade-offs

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

4.1 · Lock the class, not the request

chose
A row lock on the class inside the booking transaction
because
The last spot in a popular class is exactly where two people press book at once. Locking the class row means the second booking sees the first and fails cleanly.
cost
Bookings for the same class run one after another. At studio scale that is invisible; at much larger scale it would need a different design.

4.2 · Hold the spot while the card is paid

chose
A pending-payment booking that expires after 30 minutes
because
The spot is reserved before the customer leaves for Stripe, so a paid booking can never find the class full. A scheduled command releases spots whose payment never arrives.
cost
An abandoned checkout keeps a spot looking taken for up to half an hour.

4.3 · Payments confirmed by webhook, idempotently

chose
Stripe Checkout and signed webhooks, with completion safe to repeat
because
The browser returning from checkout proves nothing. The signed webhook is the source of truth, and unknown or repeated events are ignored, so Stripe can retry freely.
cost
The booking flips to paid a moment after the customer returns, so the screen has to handle “payment pending”.

4.4 · The domain behind ports

chose
Repositories, gateways and the clock as interfaces the domain owns
because
Booking, billing and cancellation rules are tested with fakes and a fixed clock: with no database and no Stripe, so the domain suite runs anywhere in moments.
cost
More interfaces and adapters than a framework-first app, and Doctrine mapping to keep honest.

4.5 · Rewrite early, with a parity map

chose
Rebuild the first Laravel and React version on Symfony and SvelteKit
because
The first cut proved the product; the rewrite gave it strict domain code from the start. A route-by-route parity document kept every endpoint and rule from being lost.
cost
Building the same features twice before adding any new ones.

5 · testing & delivery

Every feature lands with tests, and no commit gets past PHPStan.

The domain suite mocks every repository and gateway, so it needs no database. Functional tests drive the HTTP API against a test database, including auth, booking, cancellation and webhooks. Each roadmap item was built test first and only closed when the suite was green.

There is no CI service yet: the gate is a tracked pre-commit hook running PHPStan at level 8 with no baseline, plus svelte-check and a production build for both front ends.

$ vendor/bin/phpunit && composer phpstan
PHPUnit
~430 tests in 130 files
domain suite
~250 tests, no database
API functional
~180 tests
PHPStan
level 8 · zero baseline
front ends
svelte-check · production build
gate
pre-commit hook, no CI yet

6 · results

$ cat ROADMAP.md PHASE_*.md | grep -cE "^#+ [0-9]+\. .*✅"
roadmap items delivered
58, each with tests
domain areas
22
API routes
~200
web app screens
69
surfaces
public · portal · instructor · admin · check-in
status
pre-launch, runs end to end

Building a product with real rules and real money in it?

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