AMORÈ London•Build project — 116 phases since May 2026

A Multi-Tenant Salon Platform, Built From a Static Prototype

AMORÈ London needed a production booking system that customers would use daily and the salon team could run entirely by themselves. We delivered a Laravel + Nuxt platform spanning a customer PWA, staff and salon admin workspaces, and a multi-tenant SaaS control plane with per-salon Stripe billing.

Laravel APINuxt 4 PWAMulti-tenant SaaSStripe / RevolutMembershipsLoyaltyGift cardsiOS / Android

The brief started small and familiar: turn a static, click-through prototype of a salon booking app into real screens backed by a real API. What shipped is a vertically-integrated product — a customer app, staff and owner admin areas, memberships billed through Stripe, loyalty points, gift cards, marketing automations, an AI layer and a prepaid platform-billing ledger, and finally a multi-tenant SaaS layer where each salon gets an isolated subdomain, its own branding, and its own Stripe or Revolut account for payments. The whole system is documented operationally in a single living README and was delivered across 116 numbered build phases and more than 400 commits.

56
app screens and pages
277
API endpoints
106
database migrations
53
Eloquent models
424
commits over 6 months
116
documented build phases
  • Rebuild every screen of a static HTML prototype as production Vue components backed by real endpoints — state, validation, loading and error paths included.
  • Model genuinely hard scheduling constraints: several services per booking, staff picked per service or left as "Any Professional", buffers between appointments, holidays, availability overrides, closures and concurrent browsers.
  • Handle money correctly: card payments online, pay-at-the-salon, deposits, gift cards, promo deals and loyalty redemption — each with cancellation semantics that stay consistent, and with a salon that may not have a Stripe account at all.
  • Let two London salons share one backend with strict data isolation, then generalise that model to any number of tenants.
  • Work within mobile-first constraints: a 390px design frame, iOS and Android webview quirks (fonts, icons, safe areas) and offline-capable PWA behaviour, then ship the same web build inside native shells to both app stores.
  • Charge each salon for what it actually consumes — emails, SMS segments and AI tokens — without leaking platform cost, and without a callback being the only way a payment can land.
  • Keep the build runnable by one engineer: the spec, API map and build log all live in a README that doubles as the change log.
  • A service-layer architecture — AvailabilityEngine, LoyaltyService, CancellationService, SmartPricingService, AutomationService — keeps controllers thin and business rules testable.
  • Availability expressed as a pure function of date, services and location that consults staff schedules, overrides, closures, active holds and existing bookings before emitting slots.
  • A 15-minute slot-hold system while the customer sits on checkout, per-staff overlap tests, and an exemption so a customer never blocks their own booking.
  • Loyalty points credited on completion (not at booking), redeemed only in whole pounds, and reversed automatically when a booking is cancelled.
  • Tenant isolation enforced by an Eloquent global scope plus automatic tenant_id assignment, with subdomain middleware and an explicit X-Tenant header for the API.
  • An inline-SVG icon composable that replaced the Font Awesome CDN (95KB of CSS) and fixed an iOS font-rendering bug, plus UK date formatting across every screen.
  • Prepaid platform billing: every send is priced per salon, deducted from a wallet ledger and shown back to the salon grouped by service, with Stripe top-ups credited from three independent paths (return page, webhook, reconciliation sweep) so an undeliverable callback cannot lose a payment.
  • Availability that agrees with the law of the building: one timezone per salon decides what "today" is, a diary line the clock is inside books its next whole minute, and two switches open the past and the hours for the desk alone — never for the client booking page.
  • Two ways to take money, chosen per salon: card payments run through Stripe or Revolut behind one service, so a booking, a gift card and a monthly membership all follow the salon's choice — keys per salon, the client's card field embedded in our own page, and a ten-minute reconciliation sweep instead of a webhook as the only way a payment can land.
  • An AI layer behind one provider-agnostic gateway: the assistant answers from the salon's own help articles, flags allergies on the booking panel, and answers questions about the salon's data — with nothing sent, charged or changed unless a person accepts it.
  • Phased delivery: 34 numbered phases, each shipping working functionality with documented reasoning and regression tests for booking validation and closures.

Customer app — a real booking journey

Screens captured from the running application: four-step booking wizard, loyalty wallet, membership tiers and gift cards — on a 390px design frame built for iOS and Android webviews.

Salon operations — the owner's control room

Thirty-four admin workspaces, grouped as Overview, People, Business, Growth, Insights and Settings, cover day-to-day running of the business: the diary, services and staff, financial reporting, the revenue levers — memberships, loyalty, gift cards, deals, automated messages — plus the platform account that pays for them.

Running the diary

Team and catalogue

Revenue and marketing

Platform and settings

Architecture at a glance

One Laravel API powers every surface. Tenants are resolved from the hostname (or an explicit X-Tenant header), then every scoped query is filtered through an Eloquent global scope.

CLIENTS
Customer app — Nuxt 4 PWA
booking wizard · loyalty · memberships · gift cards
Salon staff + owner admin
diary, services, clients, reports (56 screens)
Super-admin platform console
tenant onboarding and billing status
CMS website (amorelondon.uk)
public site sharing the same API
→
LARAVEL API — bookings-api
Auth — Sanctum tokens
customer · staff · owner · super-admin roles
Domain services
AvailabilityEngine · Loyalty · Cancellation · SmartPricing · Automations
Tenancy
ResolveTenant middleware + BelongsToTenant global scope
275 endpoints · 104 migrations · 53 models
→
SERVICES
PostgreSQL 18
tenant-isolated data (one global scope)
Stripe / Revolut — per salon account
payments, subscriptions, memberships
Email (SES / Resend switchable)
booking confirmations and automations
Push (FCM)
reminder and update notifications
AI — provider switchable
assistant, allergy alerts, ask-your-data

Code logic that mattered

Seven excerpts from the codebase that explain how the hard problems were actually solved — real files, lightly trimmed for reading.

Availability — one query shape for every ruleamore-salon-backend/app/Services/AvailabilityEngine.php

Slots are generated as offsets over a combined duration, so a three-service booking behaves as one appointment. Every candidate slot is then checked against staff schedules, holidays, availability overrides, closures and existing bookings — including held slots that have not yet expired.


        // Calculate time offsets. Service 0 starts at minute 0, service 1 at
        // minute (dur0 + buffer), service 2 at (dur0 + dur1 + 2*buffer), etc.
        $totalDuration = 0;
        $offsets = [];
        foreach ($resolvedServices as $i => $rs) {
            $offsets[$i] = $totalDuration;
            $totalDuration += $rs['duration'];
            if ($i < count($resolvedServices) - 1) {
                $totalDuration += $bufferMinutes;
            }
        }

        // Fetch existing bookings for this date (include active holds)
        $existingBookings = Booking::where('booking_date', $date)
            ->whereNotIn('status', ['cancelled', 'no_show'])
            ->where(function ($q) {
                // Exclude expired holds (held_until passed)
                $q->where('status', '!=', 'held')
                  ->orWhere(function ($sq) {
                      $sq->where('status', 'held')
                         ->where('held_until', '>', now());
                  });
            })
            ->whereHas('bookingServices')
            ->with('bookingServices')
            ->get();
Tenant isolation as an Eloquent global scopeamore-salon-backend/app/Models/Concerns/BelongsToTenant.php

Any model using this trait is automatically filtered by the resolved tenant on every query, and new rows receive tenant_id on creation. A parallel creating() hook assigns the tenant — so a developer cannot forget to scope a query.


    public static function bootBelongsToTenant(): void
    {
        static::addGlobalScope("tenant", function (Builder $builder) {
            try {
                $t = tenant();
                if (!$t) {
                    return;
                }
                $table = $builder->getModel()->getTable();
                $builder->where(function ($q) use ($t, $table) {
                    $q->where($table . ".tenant_id", $t->id)
                      ->orWhereNull($table . ".tenant_id");
                });
            } catch (\Throwable) {
                // tenant() not resolved — skip scoping (e.g. in raw tinker)
            }
        });
    }
Resolving the tenant from the subdomainamore-salon-backend/app/Http/Middleware/ResolveTenant.php

The API subdomain is deliberately excluded from subdomain resolution — a frontend identifies its salon with an explicit X-Tenant header, which keeps API hosts stable for every tenant.


    public function handle(Request $request, Closure $next)
    {
        $host = $request->getHost();
        $parts = explode(".", $host);

        // Super-admin panel
        if ($subdomain === "admin") {
            return $next($request);
        }

        // API subdomain — tenant may be passed via X-Tenant header instead.
        if ($subdomain === "bookings-api" || str_ends_with($subdomain, "-api")) {
            $slug = $request->header("X-Tenant");
            if ($slug) {
                $tenant = Tenant::where("subdomain", $slug)
                    ->orWhere("slug", $slug)
                    ->first();
                if ($tenant && $tenant->is_active) {
                    app()->instance("current_tenant", $tenant);
                }
            }
            return $next($request);
        }

        $tenant = Tenant::where("subdomain", $subdomain)->first();
        if (!$tenant || !$tenant->is_active) {
            abort(404, "Tenant not found");
        }

        app()->instance("current_tenant", $tenant);
        return $next($request);
    }
The frontend derives its own tenant slugamore-salon-frontend/app/composables/useApi.ts

The Nuxt app reads its own hostname, maps the apex booking domain to the default salon, and attaches the tenant header to every request through an ofetch wrapper that also injects the Sanctum bearer token.


function tenantSlug(): string | null {
  if (!false) return null;
  const host = window.location.hostname;
  if (
    host === "localhost" ||
    host === "127.0.0.1" ||
    host.startsWith("192.168.") ||
    host.startsWith("10.")
  ) {
    return null;
  }
  const parts = host.split(".");
  // e.g. demo.localhost → tenant "demo" (local multi-tenant testing)
  if (host.endsWith(".localhost")) return parts[0].toLowerCase();
  if (parts.length <= 2) return null;
  const sub = parts[0].toLowerCase();
  if (["www", "admin", "app", "bookings-api"].includes(sub) || sub.endsWith("-api")) return null;
  // Main booking site (apex) — default/main salon tenant.
  if (sub === "bookings") return "amore";
  return sub;
}
Points are earned on completion — and reversed on cancellationamore-salon-backend/app/Services/LoyaltyService.php

Crediting happens when a booking is completed, not when it is created, so abandoned bookings never mint points. Membership tiers multiply earnings, and the same service decrements the ledger when a cancellation is processed.


        $pointsPerPound = $settings?->loyalty_points_per_pound ?? 1;
        $points = (int) floor($booking->total_price * $pointsPerPound);

        // Apply membership multiplier
        $multiplier = $this->getMultiplier($user);
        $points = (int) floor($points * $multiplier);

        if ($points <= 0) {
            return;
        }

        LoyaltyPoint::create([
            "user_id" => $user->id,
            "booking_id" => $booking->id,
            "points_earned" => $points,
            "points_redeemed" => null,
            "type" => "earned",
            "description" => "Booking #{$booking->id} — {$booking->booking_date->format(
                "d/m/Y",
            )}",
        ]);

        $user->increment("points_balance", $points);
    }
Cancellation is a rules-based refund decisionamore-salon-backend/app/Services/CancellationService.php

Refunds depend on how many hours remain before the appointment, the salon's cancellation window, deposit rules and what was actually paid. After the refund is recorded, the booking is cancelled, loyalty reversed and the cancellation automation fired.


        $cancellationHours = $settings?->cancellation_hours ?? 24;
        $depositPercentage = $settings?->deposit_percentage ?? 50;
        $requireDeposit = $settings?->require_deposit ?? false;

        $hoursUntilBooking = Carbon::now()->diffInHours($booking->booking_date, false);

        // Determine refund amount
        $totalPaid = Payment::where('booking_id', $booking->id)
            ->whereIn('type', ['full', 'deposit'])
            ->where('status', 'completed')
            ->sum('amount');

        $refundAmount = $this->calculateRefund(
            $booking,
            $totalPaid,
            $hoursUntilBooking,
            $cancellationHours,
            $depositPercentage,
            $requireDeposit
        );
Bank holidays imported from gov.ukamore-salon-backend/app/Http/Controllers/Api/Admin/ClosureAdminController.php

One admin action pulls the official England and Wales bank-holiday feed and inserts future dates as full-day closures — idempotently, skipping anything already on the calendar.


        $events = $data["england-and-wales"]["events"] ?? [];

        $imported = 0;
        $skipped = 0;

        foreach ($events as $event) {
            $eventYear = (int) substr($event["date"], 0, 4);

            if ($eventYear !== $year) continue;

            if ($event["date"] < date("Y-m-d")) {
                $skipped++;
                continue;
            }

            $exists = \App\Models\ShopClosure::where("location_id", $locationId)
                ->whereDate("closure_date", $event["date"])
                ->exists();

            if ($exists) {
                $skipped++;
                continue;
            }

            \App\Models\ShopClosure::create([
                "location_id" => $locationId,
                "closure_date" => $event["date"],
                "reason" => $event["title"],
                "source" => "gov_uk_import",
            ]);

            $imported++;
        }

How it was built — 116 phases

Every phase shipped with its own README section: what changed, which files, and the reasoning. The commit history reads as a chronological build log. Eighteen of them are picked out below.

  1. Foundation

    Schema, Sanctum authentication, role middleware and the first 58-route API skeleton.

  2. Core booking

    AvailabilityEngine plus the four-step wizard: location, professional, time, checkout.

  3. Growth features

    Loyalty points, gift cards and the first admin CRUD areas.

  4. Memberships

    Stripe-billed tiers, subscriber admin and a payment flow customers actually finish.

  5. Reports and polish

    Dashboards with charts, email templates, PWA offline behaviour and mobile UX.

  6. Icon system

    Font Awesome replaced by an inline-SVG composable — an iOS font bug and 95KB of CSS gone.

  7. CMS website

    The public amorelondon.uk site rebuilt as a Nuxt front end on the same Laravel API.

  8. Closures and availability

    Shop closures, staff holidays, seniority and per-day availability overrides.

  9. Roles and portals

    Staff self-service, role-based access control and an admin UI that works on a phone.

  10. Booking hardening

    Test-data seeding, same-user overlap exemption and the 15-minute slot-hold system.

  11. Points economy

    Credit on completion, whole-pound redemption and full reversal on cancellation.

  12. Production and mobile

    Subdomain architecture, CORS for the mobile shell and clean PWA updates.

  13. Multi-tenant SaaS

    A tenants table, subdomain resolution, a super-admin console and per-salon Stripe keys.

  14. Smart pricing and email

    Surge and off-peak pricing rules plus switchable SES and Resend delivery.

  15. AI layer

    One provider-agnostic gateway: an assistant that answers from the salon's own articles, allergy alerts on the booking panel, and questions asked of the salon's own data.

  16. Usage and prepaid billing

    Every email, SMS segment and AI token priced per salon and metered; then the arrears invoice replaced by a prepaid wallet with Stripe top-ups, a spend breakdown and a per-charge statement.

  17. The salon's own clock

    A timezone per salon, a diary that shades the time already gone to the minute, and two switches that let the desk — and only the desk — book the past and outside the hours.

  18. A second payment rail

    Revolut alongside Stripe, chosen per salon: hosted orders for bookings, gift cards and memberships, an embedded card field, saved cards charged monthly, and a reconciliation sweep so a closed tab cannot lose a payment.

  19. Help centre and native apps

    Articles the salon can rewrite or hide, a question box that records what could not be answered, and the same web build shipped to the App Store and Google Play in native shells.

The outcome

  • One backend now powers four surfaces: the customer app, staff and owner admin, the super-admin platform console and the public CMS website.
  • The platform runs as a multi-tenant SaaS — AMORÈ and a demo salon live side by side, each with isolated data, its own subdomain and its own payment account, Stripe or Revolut, switchable from Settings.
  • Booking integrity is enforced in code: 15-minute holds prevent double-booking at checkout, and availability always respects schedules, holidays, overrides, closures and active holds.
  • Revenue features are live in the product — memberships with MRR tracking, loyalty with a full points ledger, gift cards, deals, smart pricing, deposits and a rules-based refund policy.
  • Clients are contacted automatically — booking confirmations, reminders and updates through a single automation service over email, SMS and push — and can help themselves from a help centre written in the salon's own words.
  • The same code runs as a web app, an installable PWA and native iOS and Android builds, in three languages, on one API.
  • The platform bills its own salons: each one tops up a credit balance, every email, SMS segment and AI token is metered against it, and the salon can see what each service cost it — grouped, per charge, on the day it happened.
  • The codebase stayed shippable by one engineer: a test suite of 800+ cases over booking rules, billing and tenancy, 106 migrations, 277 endpoints, and a README that documents every phase.

Building a platform like this?

From a static prototype to a multi-tenant SaaS — we plan the phases so every step ships something usable.