A complete technical breakdown — from the first wireframe to the live deployment. This page covers how 9WEB designed and coded the public-facing website and the fully bespoke backend booking portal for a London Hijama and therapy practice. Every decision, every line of logic, explained.
The Cupping Guy is a specialist Hijama (wet cupping) and muscular therapy practice based in London. When 9WEB was brought on, the client had no website and no digital infrastructure at all. Appointments were managed manually through WhatsApp and phone calls — which worked when the client had a handful of patients, but quickly became unmanageable as the practice grew.
The brief had two distinct parts:
Off-the-shelf solutions like Calendly, Acuity or Squarespace bookings were considered and rejected. They either lacked the custom client record management needed, added unwanted branding, or couldn't integrate with a dedicated client portal. The decision was made to build everything bespoke.
Key constraint: The entire platform had to live on a single shared hosting environment (Hostinger) — meaning no Docker, no Node.js, no complex infrastructure. The stack had to work with PHP + MySQL + Apache, which actually suited the project perfectly.
The design brief for the public website was: trustworthy, clean, and health-focused. Hijama is a traditional Islamic and holistic therapy — the design needed to feel calm and professional, not clinical. Patients needed to feel they were in safe, expert hands before they'd even picked up the phone.
Colour palette: Deep forest green as the primary brand colour, paired with white and a warm off-white. Green was chosen deliberately — it signals healing, nature and wellbeing without the starkness of a medical white. A teal accent was used for calls-to-action to create contrast and guide the eye toward booking buttons.
Typography: A clean sans-serif (Plus Jakarta Sans) throughout — readable at all sizes, professional, never cold. Headers carry appropriate weight without feeling aggressive.
Page structure built:
Mobile-first approach: The majority of health and therapy searches happen on mobile. Every page was designed mobile-first — the desktop layout is an enhancement, not the primary focus. Touch targets, readable font sizes and fast-loading images were non-negotiable.
SEO decision: Each treatment got its own dedicated URL (e.g. /services/hijama-london, /services/fire-cupping-london). This meant each page could rank individually for its own treatment-specific search terms — rather than burying everything on a single services page which would dilute ranking potential.
The website frontend was built in clean, semantic HTML5 with custom CSS3 — no Bootstrap, no heavy UI framework. This kept the site fast (critical for Google Core Web Vitals) and gave us full control over every pixel.
Why no framework? Bootstrap and Tailwind are excellent tools, but they add overhead. For a focused 6-page site, a purpose-built stylesheet is lighter, faster and simpler to maintain. The CSS was structured using custom properties (variables) for the colour palette and spacing system, making global changes trivial.
/* Brand variables — change here, updates everywhere */ :root { --green-primary: #1a6b35; --green-dark: #0f3a1f; --teal-accent: #00bfa6; --white: #ffffff; --off-white: #f7f9f4; --font-body: 'Plus Jakarta Sans', sans-serif; --radius: 10px; --shadow: 0 8px 32px rgba(15, 58, 31, 0.12); }
JavaScript: Vanilla JS only — no jQuery, no React. The scripts handled mobile navigation toggling, scroll-triggered animations using the Intersection Observer API, smooth anchor scrolling and form validation on the contact page.
// Reveal elements as they scroll into view const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('visible'); observer.unobserve(entry.target); } }); }, { threshold: 0.12 }); document.querySelectorAll('.reveal').forEach(el => { observer.observe(el); });
Performance: Images were compressed and served in WebP format where supported. Fonts were loaded with display=swap to prevent invisible text during load. The site consistently scores 90+ on Google PageSpeed Insights — which directly impacts search rankings.
The booking portal is backed by a relational MySQL database. Getting the schema right at the start is critical — a poorly structured database creates problems that compound over time. We mapped out all the entities, their relationships and their constraints before writing a single line of PHP.
The database has five core tables:
The appointments table is the heart of the system. Its status column uses an ENUM — 'pending', 'confirmed', 'cancelled', 'completed' — which constrains the value at database level, not just application level. This prevents dirty data from ever entering the system.
Foreign key constraints between appointments → clients and appointments → services ensure referential integrity — you can never have a booking for a non-existent client or a deleted service.
Key design decision: Working hours are stored in availability by day-of-week (0=Monday, 6=Sunday) rather than hardcoded. This means the practitioner can change their working hours from the admin panel without any code changes.
The backend is built in PHP 8 following a clean endpoint-per-action pattern. Rather than a heavy MVC framework, we used a lightweight router that maps URL paths to handler functions — keeping the codebase minimal and easy to reason about.
All database access uses PDO with prepared statements. This is non-negotiable — it is the only correct way to interact with a MySQL database in PHP. String concatenation in SQL queries creates SQL injection vulnerabilities; prepared statements make that structurally impossible.
// ✅ Correct — prepared statement, SQL injection impossible function getClientByEmail($pdo, $email): array|false { $stmt = $pdo->prepare( 'SELECT * FROM clients WHERE email = :email LIMIT 1' ); $stmt->execute(['email' => $email]); return $stmt->fetch(PDO::FETCH_ASSOC); } // ❌ Wrong — NEVER do this (SQL injection risk) // $result = $pdo->query("SELECT * FROM clients WHERE email = '$email'");
The core API endpoints built:
Creates a new client account. Validates all fields server-side, checks the email isn't already registered, hashes the password with password_hash($pw, PASSWORD_BCRYPT), inserts the record and returns a session token.
Fetches the client record by email, verifies the password with password_verify(), regenerates the session ID with session_regenerate_id(true) to prevent session fixation, then stores the client ID in the session.
The most complex endpoint. Fetches the practitioner's working hours for that day of week, generates all possible slots based on service duration, then subtracts any slots already booked that day and any blocked dates. Returns an array of available time strings.
Validates the client is logged in, re-checks the slot is still available (to handle race conditions between two clients booking simultaneously), creates the appointment record with status 'confirmed', then triggers the email confirmation job.
Checks the appointment belongs to the requesting client, verifies it's more than 24 hours away (business rule), updates status to 'cancelled' and fires a cancellation notification to both client and practitioner.
Returns all upcoming and past appointments for the authenticated client, joined with service names and formatted dates. Results are ordered by appointment date descending — most recent first.
The availability engine is the most technically interesting part of the system. It needs to answer a simple question — "what times are free on this date for this service?" — but doing so correctly requires several steps.
function getAvailableSlots($pdo, $date, $service_id): array { // 1. Is this date blocked entirely? $blocked = isDateBlocked($pdo, $date); if ($blocked) return []; // 2. Get working hours for this day of week $dayOfWeek = (int) date('N', strtotime($date)) - 1; $hours = getWorkingHours($pdo, $dayOfWeek); if (!$hours) return []; // day off // 3. Get service duration $duration = getServiceDuration($pdo, $service_id); // 4. Generate all possible slots $allSlots = generateSlots( $hours['start_time'], $hours['end_time'], $duration ); // 5. Remove already-booked slots $booked = getBookedTimes($pdo, $date); return array_diff($allSlots, $booked); }
Handling race conditions: Two clients could theoretically see the same slot available at the same moment and both try to book it. To handle this, the final booking write uses a database transaction with a SELECT ... FOR UPDATE lock on the relevant time slot row. If both requests arrive simultaneously, the database serialises them — the first commits successfully, the second gets a conflict error and the user sees a "sorry, that slot was just taken" message.
Duration-aware slots: A 30-minute Hijama session generates slots at 09:00, 09:30, 10:00 etc. A 60-minute deep tissue massage generates slots at 09:00, 10:00, 11:00. The slot generator respects the service duration — a 60-minute booking at 09:00 blocks 09:00 AND prevents anything starting at 09:30 (which would overlap). This is calculated by comparing booked slot start times against the full duration window, not just the start time.
Any system handling personal health data and client records has a serious obligation to be secure. We took a layered approach — multiple independent security controls so that if one fails, others still protect the system.
password_hash() with PASSWORD_BCRYPT. Plain text passwords are never stored anywhere — not in the database, not in logs, not in emails.session_regenerate_id(true) is called to issue a new session ID. This defeats session fixation attacks where an attacker pre-sets a known session ID.htmlspecialchars() on output and validated server-side on input. Client-side validation is a UX feature — server-side validation is the actual security control.client_id matches the authenticated session user. Client A cannot read, cancel or modify Client B's bookings.Automated emails are a core feature of the booking experience. Clients expect an immediate confirmation when they book, and a reminder before their appointment. We built this using PHPMailer — the industry standard PHP email library — sending via a dedicated SMTP relay for reliable delivery.
Why not PHP's built-in mail() function? Because it's unreliable on shared hosting, offers no authentication, and gets flagged as spam far more often. PHPMailer with SMTP authentication gives us proper delivery, TLS encryption, and bounce handling.
function sendBookingConfirmation($client, $appointment, $service): bool { $mail = new PHPMailer(true); $mail->isSMTP(); $mail->SMTPAuth = true; $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Username = getenv('SMTP_USER'); // from .env $mail->Password = getenv('SMTP_PASS'); // never hardcoded $mail->addAddress($client['email'], $client['first_name']); $mail->addBCC('bookings@thecuppingguy.com'); // practitioner copy $mail->Subject = 'Your appointment is confirmed — The Cupping Guy'; $mail->isHTML(true); $mail->Body = renderEmailTemplate('confirmation', [ 'name' => $client['first_name'], 'service' => $service['name'], 'date' => formatDate($appointment['appt_date']), 'time' => $appointment['appt_time'], ]); return $mail->send(); }
SMTP credentials security: Notice the credentials are fetched from getenv() — they live in a .env file on the server, outside the web root, and are never committed to any repository. This is standard practice and means credentials are never exposed even if source code is somehow leaked.
Three automated emails are sent:
The admin panel is the practitioner's command centre. It lives at /admin — a route that checks for an admin session on every request. No admin session means an immediate redirect to the admin login page, with no information about what's behind the route.
Admin capabilities built:
Soft deletes everywhere: Nothing in the admin panel hard-deletes data. Services are deactivated, not deleted. Appointments are cancelled with a timestamp, not removed. This preserves historical records — critical for a health practice that may need to reference past treatments.
The platform is hosted on Hostinger shared hosting — deliberately. The client didn't need a VPS or cloud server; shared hosting with PHP 8 and MySQL is perfectly capable of handling this traffic volume, costs a fraction of the price, and is far simpler to manage.
Before going live, a deployment checklist was worked through:
.htaccess.env outside web root — no credentials in codedisplay_errors = Off) — errors log to file, never shown to usersThe full project — website design, frontend code, database design, backend API, admin panel, email system and deployment — was completed and delivered by 9WEB. The platform has been running reliably since launch with zero unplanned downtime.
Want something similar built for your business? Whether it's a booking system, a client portal, a membership platform or bespoke business software — 9WEB can scope, design and build it. Book a free 30-minute call and we'll tell you exactly how we'd approach your project.
9WEB designs and builds custom web applications, booking systems and client portals — tailored precisely to your workflow. Book a free call and let's talk through your requirements.