Online Booking lets visitors book appointments straight from the website: pick a service, pick a staff member (or "any available"), pick a day and time, and confirm — with an instant confirmation email, a calendar invite, automatic reminders, and optional Stripe payment at booking time. Every booking lands in the CRM, so the person who booked today is reachable by every follow-up tool the site has.
The problem
A service business's calendar lives in one place and its website in another. "Call to book" loses the after-hours visitor; a Calendly link bolts a third-party brand (and bill) onto the site and keeps the customer data on someone else's servers; and none of it talks to the site's own contact list, so the person who booked last month isn't reachable when the newsletter goes out.
The fix
A self-contained feature module under app/Features/OnlineBooking/ gives the site its own booking system: services, staff with weekly availability, a public booking wizard, confirmations with calendar invites, reminders, and Stripe-paid bookings — all in the install's own database. Bookings create CRM contacts automatically (source Booking), fire CRM automations, and log to the contact's timeline.
Like every addon, it's structured as a feature module gated by feature:online_booking middleware. The service provider boots unconditionally; routes 404 and the sidebar entry hides when the feature is off. Toggling it on runs the module's migrations and materializes the editable /book page.
What visitors see
The public booking wizard walks four steps:
- Service — active services with duration, description, and price ("Free" or the amount). Group classes say so ("Group class — up to N people per time") and deposit services show the amount due at booking.
- Staff — shown only when more than one team member performs the service; "Any available" books the first open person.
- Day & time — a month calendar highlighting days with open slots, then a grid of start times. Times are shown in the visitor's own timezone (detected in the browser) — a New York customer booking a Chicago business sees New York times. Group classes show spots left on every time.
- Details — name, email, phone, an optional note, the service's intake questions (short/long answer, dropdown, or checkbox — required ones enforced), and (when text reminders are on) an SMS opt-in checkbox. When a cancellation window applies, the form says how late changes stay possible.
Free services confirm instantly. Priced services go to an embedded Stripe checkout — the slot is held while they pay and released automatically if checkout is abandoned, so a slow payer can't lose their spot and an abandoner can't block it. Services with a deposit collect just the deposit at checkout; the balance is settled at the appointment and tracked on the dashboard.
Every confirmed booking gets:
- A confirmation email with the details, an attached .ics calendar invite, and a manage link.
- A manage page (tokenized link, no login) where the visitor can add the event to their calendar, reschedule to any open slot, or cancel.
- A reminder email N hours before the appointment (default 24), plus an SMS reminder when the visitor opted in and text reminders are enabled.
What the dashboard gets
Four pages under Dashboard → Booking:
- Appointments — upcoming / past / cancelled tabs with search and staff/service filters, status actions (completed, no-show, cancel — with an optional one-click Stripe refund on paid bookings), and a detail view showing intake answers, the outstanding balance due on deposit bookings, the customer's prior no-show count, and a link to the CRM contact. Marking a no-show also logs it to the CRM timeline, so repeat offenders are visible wherever the contact shows up.
- Services — name, description, duration, buffer before/after, optional price, optional deposit, capacity (above 1 makes it a group class), a per-service cancellation window override, intake questions, active toggle, and which staff perform it.
- Staff & Availability — team members with per-person weekly hours (any number of windows per weekday), date overrides (mark a date unavailable, or give it special hours that replace the weekly ones), per-person calendar connections (Google / Outlook), and a per-person read-only calendar feed subscribe link (see below). New staff start with a Mon–Fri 9–5 template to tweak.
- Settings (admin only) — minimum notice, booking horizon, slot spacing, "any available staff" assignment strategy, the global cancellation window, reminder timing, SMS reminders toggle, transactional sender, owner notifications, currency, the Stripe webhook secret, and the calendar-sync OAuth credentials.
The owner can also get an email notification for every new booking (Settings → Notify on new bookings).
Scheduling rules
- Availability windows are weekly wall-clock hours per staff member, interpreted in the site timezone (Settings → Business). A 9:00 window is 9:00 local on both sides of a daylight-saving change.
- Date overrides — vacation days and special hours per staff member. Any override rows for a date replace that day's weekly windows: a "unavailable" override closes the whole day, custom hours define the special schedule.
- External busy times — events on a connected Google/Outlook calendar block slots (see Calendar sync below).
- Buffers — per-service prep/cleanup minutes before and after; buffered time blocks neighbouring slots.
- Group classes — a service with capacity above 1 lets the same time be booked by multiple visitors until it fills; a different booking overlapping the time still blocks it outright.
- Minimum notice — how close to the start a slot stays bookable (default 4 hours).
- Horizon — how far ahead visitors can book (default 60 days).
- Slot spacing — offer start times every 10/15/20/30/60 minutes, or step by the service's own duration.
- "Any available staff" assignment — Priority order (staff sort order, the default) or Balanced, which prefers the member with the fewest bookings that day so new bookings round-robin across the team.
- Cancellation windows — a global (or per-service) number of hours before the start after which visitors can no longer self-cancel or reschedule from the manage page; the window in force is snapshotted onto each appointment. Dashboard staff can always cancel.
- No double-booking — slots are re-validated inside a transaction at submit time; two visitors racing for the same time can't both win. Pending Stripe checkouts hold their slot until they pay or the hold expires.
Appointments store their service and staff details as snapshots, so editing or deleting a service never corrupts history or shifts existing bookings.
Payments
Priced services use the same site-wide Stripe keys as the shop and donations (Settings → API Keys) with a booking-specific webhook endpoint (/book/stripe/webhook) and signing secret. Confirmation happens on whichever of the webhook or the return page lands first — exactly once. Cancelling a paid booking from the dashboard offers an optional full refund through Stripe; refunds arriving from Stripe's side (webhook) are recorded on the appointment too.
Deposits. A service can set a deposit below its price; Stripe then collects only the deposit at booking time ("Service (deposit)" on the checkout). The full price, the deposit, and the amount actually paid are all snapshotted on the appointment, so the dashboard detail view shows the outstanding balance due to collect at the appointment.
Promo codes. Step 4 offers a code field. A code discounts what is charged at booking time — the deposit on a deposit-priced service, not the full price — and is folded straight into the single Stripe line rather than riding a coupon. Codes come from the shared list at Dashboard → Promo Codes, and are mutually exclusive with gift cards/credit/points.
Book now, pay later. A completed appointment with money still outstanding — never paid at all, or only deposited on — gets a Bill this appointment action in the bookings list (Past tab), which raises a draft invoice in Client Billing for exactly the unbilled remainder: one line naming the service, date and staff member. The visitor becomes a Client Billing client at that moment, matched on email so a repeat customer keeps one record, created as a Prospect (signing a contract is what makes them Active) and mirrored into the CRM per the invoicing sync setting. Draft rather than sent, because materials or extra hours usually get added before it goes out; sending stays on the invoice where it already lives. Idempotent — the appointment keeps its invoice id and the action disappears. Requires Client Billing; free and fully paid bookings never offer it.
Gift cards, account credit & points. With Gift Cards and/or Loyalty enabled, step 4 offers a gift-card field, an account-credit toggle and a points field, and shows the resulting Due now. Credits are measured against what is actually charged at booking time — the deposit on a deposit-priced service, not the full price — so the balance settled in person is untouched. They ride into Stripe as a one-off coupon, are captured only when the booking pays, and are returned on a full refund. A 50c minimum stays card-payable. See gift-cards.md.
Calendar sync (Google & Outlook)
Each staff member can connect their own Google Calendar or Outlook/Microsoft 365 calendar from Booking → Staff & Availability:
- Busy times block slots. Events on the connected calendar (except ones marked free/transparent) are pulled into a local busy cache every 15 minutes (LazyCron
booking:sync-calendars) and excluded by the slot engine — a dentist's personal appointment blocks the website's 2 pm slot. All-day events block the whole day. - Bookings appear on their calendar. Confirmed appointments are pushed as events (with the customer's details in the description), updated on reschedule, and removed on cancellation. Events this CMS pushed are recognized during the busy pull so they never double-block.
- Self-hosted OAuth. The site admin supplies their own Google / Microsoft OAuth client credentials in Booking Settings (redirect URIs shown right there) — the same self-hosted-client model as the Reviews Google Business Profile connection. Per-staff tokens are stored encrypted and refreshed automatically; sync errors surface on the staff card.
Calendar pushes are fire-and-forget: a provider outage can never break a booking.
Staff calendar feed (no OAuth)
For staff who don't want to connect an account — or use a calendar the two-way sync doesn't cover (Apple Calendar, Thunderbird, a shared team calendar) — each staff member also gets a read-only ICS subscribe feed. From the staff member's edit modal (Calendar sync → Calendar feed), click Create feed link, copy the URL, and paste it into any calendar app's "subscribe by URL" option; the app then polls it on its own schedule.
- What's in it: the member's confirmed appointments from the past 7 days through the next 180 days, one VEVENT each, times as UTC instants.
- Privacy by design: each event's title is just the service name plus the customer's first name ("Consultation — Pat"); customer notes, emails, phone numbers, and last names never enter the feed, so the URL can safely live in a third-party calendar app.
- Tokenized + rotatable. The URL (
/book/staff-calendar/{token}.ics) carries a random capability token — no login. The Regenerate button rotates the token, instantly invalidating any previously shared link. - One-way only: it never blocks slots or accepts changes — that's what the OAuth sync above is for.
Booking through the AI chat
When the AI Chat feature is on, the assistant gets three tools (toggleable under AI Chat → Settings → Book appointments): list the bookable services, check open times, and — after the visitor confirms a specific time and shares their name and email — book free services directly in the conversation. The visitor gets the same confirmation email, CRM capture, and manage link as a widget booking; the same per-IP rate limits apply. Paid services are never booked in chat — the assistant links to the booking page, where payment is collected.
CRM integration
Every confirmed booking:
- creates or updates a CRM contact (source Booking, matched by email — existing contacts are never clobbered),
- re-fires source-specific contact captured automations for returning customers (so "booking made → enroll in the prep-email sequence" works with one CRM automation rule),
- logs a Booking interaction on the contact's timeline (booked / rescheduled / cancelled, linked to the appointment record).
All of it is fire-and-forget: a CRM hiccup can never break a booking, and everything no-ops cleanly when the CRM is off.
Reminders & consent
Reminders run on the built-in no-cron scheduler (LazyCron, checked every 5 minutes, lock-guarded, resume-safe — an interrupted pass never double-sends). Email reminders go to everyone; SMS reminders only go to visitors who ticked the opt-in checkbox, and STOP suppression always sticks (the same phone-keyed opt-out list the Marketing feature honors). Twilio credentials are shared with Marketing → Settings.
The editable /book page
Enabling the feature materializes an editable /book page (theme source: resources/themes/{theme}/feature-pages/online-booking/) — the heading and copy around the booking widget are edited in the page editor like any page, and a Booking → Appointment Form row in the design library drops the wizard onto any other page. The transactional pages (checkout, the Stripe return page, the manage page) stay module-owned and are never cached or page-edited.
The member account page
Signed-in visitors get /members/appointments (the Appointments pill in the account nav, plus a card on the account dashboard showing the next booking). It lists every appointment booked with the account's email — upcoming first, then past — and links each row to the tokenized manage page. Rescheduling and cancelling deliberately stay on that manage page: it owns the cancellation window, the Stripe refund path and the calendar resync, so those rules have exactly one implementation. Abandoned pending_payment holds are excluded — checkout debris, not a booking. See memberships.md → The account area.
What it's not (yet)
- No online balance collection. Deposit bookings track the balance due; collecting it happens at the appointment (or manually in Stripe).
- No multi-spot bookings. One booking = one spot in a group class; a visitor bringing three friends books (or is booked) three more times.
- No recurring appointments. Each booking is a single occurrence.
- No cancellation fees. The cancellation window closes self-service changes; it doesn't charge a fee for late cancellations.
Technical notes (for developers)
- Module:
app/Features/OnlineBooking/(keyonline_booking, default OFF). Livewire namespaceonline_booking; routesdashboard.booking.*(manager; settings admin) + publicbook.*. - Tables:
booking_staff,booking_services,booking_service_staff,booking_availability_windows,booking_date_overrides,booking_service_questions,booking_calendar_connections,booking_external_busy,booking_appointments,booking_email_log. Appointment times stored UTC;timezonerecords the visitor's display zone; windows/overrides are wall-clock in the site timezone. Appointments snapshotdeposit_cents,cancellation_window_hours, intakeanswers({label: answer}), andexternal_event_ids({connection id: provider event id}). - Slot engine:
Support/SlotEngine.php— pure availability math (effective windows [weekly ∪ date overrides] − blocking appointments − external busy − buffers, min-notice + horizon, group-capacity occupancy, least-busy ordering), DST-honest, widget-agnostic (the chat tools call it directly). Booking writes:Support/AppointmentBooker.php(transactional re-validation, CRM push, emails, calendar push,markNoShow). - Calendar sync:
Support/BookingCalendarSync.php(push create/update/cancel +pullBusyreplace-wholesale busy cache, own-event exclusion) overSupport/GoogleCalendarClient.php/Support/OutlookCalendarClient.php(shared abstractCalendarClient: lazy token refresh, encrypted credentials). OAuth connect/callback:Http/Controllers/CalendarOAuthController.php(session-state CSRF, Reviews-GBP pattern). Pull cadence:Console/SyncCalendarsCommand.php(booking:sync-calendars, LazyCron 900s). - Staff ICS feed:
booking_staff.ics_token(nullable unique;BookingStaff::rotateIcsToken()is the only writer — not mass-assignable) →GET book/staff-calendar/{token}.ics(book.staff-calendar.ics, inapp/Features/OnlineBooking/routes/book.phpso it loads regardless of/bookpage-injection state, throttled 30/min) →Http/Controllers/StaffCalendarFeedController.php→IcsBuilder::forStaff()(confirmed only, −7d → +180d, SUMMARY = service + first name, no DESCRIPTION). - AI chat tools:
list_booking_services/check_booking_availability/book_appointmentinapp/Features/AiChatBot/Support/ChatToolbox.php, gated byFeatures::enabled('online_booking')+ai_chat.booking_enabled; free services only, per-IP rate-limited. - Payments:
Services/OnlineBookingStripe.php(sharedstripe.key/stripe.secret, ownstripe.webhook_secret.booking),Support/BookingCheckoutStarter.php(pending hold + embedded session, fingerprint reuse, hold + Stripe session expire together),Support/BookingFinalizer.php(atomic paid-claim shared by webhook + return page),Http/Controllers/StripeWebhookController.php(CSRF-exempt inbootstrap/app.php). - Emails:
Support/BookingEmails.phpthrough the sharedCampaignMailertransport with a booking sender override; idempotency claims inbooking_email_log;Support/IcsBuilder.phpbuilds the .ics (attached viaCampaignMailer::send()'s attachments support — server + all four HTTP providers). - Reminders:
Console/SendRemindersCommand.php(booking:send-reminders, LazyCron 300s) — also sweeps expired pending-payment holds. - Page generator:
Support/OnlineBookingPageGenerator.php, invoked fromFeatureActivator::runPostEnableHooks();themes:capturere-snapshots the page when the feature is enabled. - Settings keys:
booking.min_notice_hours,booking.horizon_days,booking.slot_step_minutes(0 = service duration),booking.any_staff_strategy(priority|least_busy),booking.cancellation_window_hours,booking.reminder_hours,booking.reminder_sms_enabled,booking.currency,booking.email.from_name/from_email,booking.notify_email,booking.google_client_id/google_client_secret/outlook_client_id/outlook_client_secret,stripe.webhook_secret.booking,ai_chat.booking_enabled. - Spam protection on the public form: honeypot + per-IP rate limiting (the Form-builder Spam Shield is bound to
Formrecords and doesn't apply here; paid bookings are additionally gated by Stripe itself). - Tests:
tests/Feature/OnlineBooking*.php(slot engine incl. DST transitions, booking flow, payments/webhook, dashboard, reminders, page generator,OnlineBookingV2Test.phpfor overrides / group capacity / least-busy / calendar sync / deposits / cancellation windows / intake questions / no-show CRM / chat tools, andOnlineBookingStaffIcsFeedTest.phpfor the staff ICS feed + token rotation).