Skip to main content

Documentation

No results found.
Features Members

Online Booking

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, automati...

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:

  1. 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.
  2. Staff — shown only when more than one team member performs the service; "Any available" books the first open person.
  3. 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.
  4. Details — name, email, phone, an optional note, the service's intake questions (short/long answer, dropdown, or checkbox — required ones carry an asterisk, are required + aria-required for the browser and screen readers, and are enforced on the server; optional ones say "optional"), and (when text reminders are on) an SMS opt-in checkbox. When a cancellation window applies, the form says how late changes stay possible.

Service calls — the dispatch model for trades

Electricians, plumbers, and HVAC companies don't book a named person at 2:15; they promise an arrival window and dispatch a tech later. Switch Booking → Settings → Scheduling model to Service calls and every booking page and row on the site swaps to a five-step request wizard instead — no page edits, and the same appointment record, confirmation, reminders, manage page, and CRM capture behind it:

  1. Contact — first name, last name, phone, email, and (when text reminders are on) an SMS opt-in. This comes FIRST on purpose: the moment it is submitted the visitor becomes a CRM contact (source Booking), so a request abandoned at any later step is still a lead worth calling back.
  2. Address — street, city, state, ZIP, and an optional "I am the homeowner" checkbox. The address is checked against the service area (below). An address outside it stops here with a message and the business phone number, and the visitor can correct the address and try again; an address the check cannot place is let through and flagged for the dashboard rather than refused.
  3. Details — which service is needed (a priced service shows its fee as due at the visit — service calls never collect online), a free-text "what's going on", and the service's intake questions.
  4. Calendar — a month view of days with an open window, then the day's arrival windows ("Morning · we'll arrive between 8:00 AM – 12:00 PM").
  5. Confirm — a summary with a Change link per step, then one click books it. The confirmation email, the .ics invite (its location is the customer's address), the manage page, and the reminders all read the window and the address instead of an exact time.

Arrival windows live under Booking → Settings in service-calls mode: each has a name, a from/to time (site timezone), how many calls it can take per day, and the weekdays it is offered on; closed dates take whole days off. A window is offered only while it still has capacity and clears the minimum-notice and horizon rules. Staff, slot spacing, and "any available staff" assignment don't apply and are hidden.

With Jobs & Dispatch on, every confirmed service call becomes a job with an unassigned visit in its window, and the customer rescheduling or cancelling moves or cancels that visit (see "Where jobs come from" there). A window's capacity is the number of calls the business can actually run in it, so set it to how many techs you can send in that window — the booking side counts calls, not techs, and nothing warns when more calls are booked into a window than there are techs to cover them. A service call that became a job is billed from the job (the bookings page's Bill action hands back the job's invoice instead of making a second one).

The service-area check is a core setting (Settings → Business → Service Area Check) so a contact form can adopt it later: ZIP / postal code list (750* matches a prefix) or distance from your address in miles, measured from the geocoded business address. Half-configured modes (an empty list, no business address) read as "unverified" rather than turning every visitor away, and the dashboard badges any appointment whose address was outside the area or could not be verified.

Book Online popup. The design library's Booking → Book Online Button & Popup row puts a headline, copy, and a Book Online button on any page; the button opens the wizard in a popup over the page, and so does any other link on the site whose URL ends in #book-online — so a header button works without a dedicated page.

Google Business Profile. Put the same page — https://your-domain/book — in the profile's Bookings → appointment link so Google shows a Book Online button on the listing. With SEO Pro, the Business Listing audit grades that link: it fails while the button is missing and warns while it points anywhere but the site's booking page.

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, a payment badge per row (Deposit paid in amber while a balance is still due, Paid once nothing is, Refunded once everything paid has gone back — a part refund keeps the badge it had, and the detail view shows the amount refunded), status actions (completed, no-show, cancel — with an optional one-click Stripe refund of the amount actually taken on paid bookings, less anything already refunded in Stripe, so a deposit booking offers the deposit, not the price, and a booking part-refunded from the Stripe dashboard still offers the rest), 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 — subscribe it to checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired and charge.refunded. Confirmation happens on whichever of the webhook or the return page lands first — exactly once, and so does the outbound booking.created webhook. Cancelling a paid booking from the dashboard offers an optional refund through Stripe of whatever is left of the payment — the same amount the cancel then refunds; refunds arriving from Stripe's side (webhook) are recorded on the appointment too.

Bank payments (ACH). A visitor can pay by US bank account when your Stripe account offers it — but only for an appointment at least four days away (DelayedPayment::MIN_LEAD_DAYS); sooner, checkout offers cards and wallets only. A debit takes up to four business days to settle or bounce, and a slot must not be held on money that may never arrive.

  • While it clears the appointment stays pending_payment, stamped payment_processing_at and badged Payment processing: its hold expiry is cleared, so it keeps blocking its slot, the hold sweep and a new checkout by the same visitor leave it alone, and any gift card / credit / points stay reserved. Nothing is confirmed yet — no confirmation email, calendar push, CRM entry or promo redemption. The return page hands the visitor to the appointment page, which shows the processing notice and offers no reschedule or cancel; staff see it in Upcoming (view only — it can't be cancelled until the payment settles), the member page lists it labelled, and the API reports status: processing.
  • When it clears (checkout.session.async_payment_succeeded) it confirms exactly like a card booking — emails, calendar, CRM, credits captured, booking.created — once.
  • If it bounces (checkout.session.async_payment_failed) it is cancelled with payment_failed_at (badged Payment failed), its slot and credit reservation are released, and the visitor gets one email saying the payment didn't go through, with a Try again button back to the booking page. No booking.cancelled goes out — the booking never confirmed.
  • If Stripe never says how it ended — the webhook isn't subscribed to the two checkout.session.async_payment_* events, or Stripe gave up delivering them — the payment is checked against Stripe once it has been processing for a day (php artisan payments:reconcile-processing, every six hours) and settled or failed exactly as the webhook would have, with a warning in the log.

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. What that invoice collects (less refunds) counts against a deposit booking's balance due — on the dashboard, the manage page and My Appointments — so a remainder paid by invoice stops reading as owed. With Client Billing off the appointment never reads the invoice.

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 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 — but one whose bank payment is still clearing is listed, labelled Payment processing (and a bounced one Payment failed). 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.
  • Service calls are one model per site, and never paid online. The scheduling model is site-wide (a company can't offer staff-slot appointments and dispatch windows side by side), and a priced service in service-calls mode shows its fee as due at the visit rather than collecting it. Staff calendar sync doesn't apply to service calls (no staff is assigned). The AI chat booking tools follow the model: in service-calls mode they list arrival windows and book one once the visitor has given a phone number and the full service address (checked against the service area), a priced service included since the fee is paid at the visit.

Technical notes (for developers)

  • Module: app/Features/OnlineBooking/ (key online_booking, default OFF). Livewire namespace online_booking; routes dashboard.booking.* (manager; settings admin) + public book.*.
  • 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; timezone records the visitor's display zone; windows/overrides are wall-clock in the site timezone. Appointments snapshot deposit_cents, cancellation_window_hours, intake answers ({label: answer}), and external_event_ids ({connection id: provider event id}).
  • Service calls: booking.scheduling_model (appointments | service_calls), booking.arrival_windows (normalised by BookingSettings::normalizeArrivalWindows() — keys are stable across saves because appointments reference them), booking.arrival_closed_dates, booking.service_calls.homeowner_question. Engine: Support/ArrivalWindowEngine.php (windows per business-zone day minus blocking bookings sharing arrival_window_key + starts_at). Booker: AppointmentBooker::bookServiceCall() / rescheduleServiceCall() (active service rows are the lock). Appointment columns: address, address_city, address_state, postal_code, address_lat, address_lng, is_homeowner, service_area_status, arrival_window_key, arrival_window_label; Appointment::isServiceCall() and the whenLabel*() helpers are what every renderer shares. Widget: widget/⚡service-request.blade.php, reached via widget.booking-form's delegation. Service area: core App\Support\ServiceArea (+ ServiceAreaCheck, App\Enums\ServiceAreaVerdict), Settings business.service_area.{mode,zips,radius_miles,center_address,out_of_area_message}. Tests: tests/Feature/OnlineBookingServiceCallsTest.php, OnlineBookingServiceRequestTest.php, ServiceAreaTest.php.
  • 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 + pullBusy replace-wholesale busy cache, own-event exclusion) over Support/GoogleCalendarClient.php / Support/OutlookCalendarClient.php (shared abstract CalendarClient: 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, in app/Features/OnlineBooking/routes/book.php so it loads regardless of /book page-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_appointment in app/Features/AiChatBot/Support/ChatToolbox.php, gated by Features::enabled('online_booking') + ai_chat.booking_enabled; free services only, per-IP rate-limited.
  • Payments: Services/OnlineBookingStripe.php (shared stripe.key/stripe.secret, own stripe.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 — it only confirms an appointment still pending_payment, never one cancelled meanwhile; bank payments: processing mark, failFromAsyncSession(), settleIfCompleted() for a session Stripe refused to expire), Http/Controllers/StripeWebhookController.php (CSRF-exempt in bootstrap/app.php).
  • Emails: Support/BookingEmails.php through the shared CampaignMailer transport with a booking sender override; idempotency claims in booking_email_log; Support/IcsBuilder.php builds the .ics (attached via CampaignMailer::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 from FeatureActivator::runPostEnableHooks(); themes:capture re-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 Form records 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.php for overrides / group capacity / least-busy / calendar sync / deposits / cancellation windows / intake questions / no-show CRM / chat tools, and OnlineBookingStaffIcsFeedTest.php for the staff ICS feed + token rotation).