WebBlocks Appointments

Requirements

Documented package version: 0.12.2. WebBlocks CMS ^1.73.0; PHP >=8.3.

A WebBlocks CMS plugin that lets a site take bookings on its own domain, instead of linking visitors out to a third-party scheduling service.

Install

Build the artifact, then install it through System → Plugins in the CMS admin using the normal ZIP upload flow.

composer plugin:build

The artifact and its SHA-256 land under build/. Plugins install disabled; enable the plugin explicitly from the plugin detail screen after reviewing it.

Conventions

Everything the plugin owns is namespaced by its handle, per the CMS plugin package convention rules:

handle              webblocks-appointments
settings namespace  webblocks_appointments
database prefix     webblocks_appointments_
admin routes        /webadmin/plugins/webblocks-appointments
public routes       /plugins/webblocks-appointments
route names         webblocks.plugins.webblocks_appointments.*
permissions         webblocks-appointments.view, .manage, .settings

The booking form

Add the Appointment Form block to a page. Choosing a service or day fetches available times without a full page reload. With JavaScript disabled, the server-rendered GET form remains available. Every booking is submitted to the server, which rechecks availability.

The title, intro and submit label can be translated per block placement. Only the block's copy is per-block. Services, staff and opening hours are site-wide, because duplicating them per block is how two booking pages end up quietly disagreeing about opening hours.

Two protections worth knowing about, because neither is visible in the markup:

  • Submitted slots are re-derived, not trusted. The booker enforces conflicts but not the rules that decide what should have been offered — opening hours, lead time, the horizon. Without the recheck a crafted post could book 03:00 on a closed Sunday, since nothing about that collides with an existing appointment.
  • source_url is only honoured as a same-site path. An absolute URL would make the form an open redirect.

Notifications

When a booking lands, the business gets an announcement and the visitor gets a confirmation carrying the appointment as an .ics attachment — which puts it in their own calendar with no account, no OAuth and no external service. The business copy sets the customer as reply-to, so a booking can be answered by replying to it; the From address stays the configured sender, because putting the visitor there fails SPF.

Three rules are worth knowing:

  • Notification happens after the booking commits, never inside the transaction. A send that throws must not roll back a slot the visitor has already been told is theirs.
  • The two sides are attempted and recorded independently. A mistyped business recipient must not suppress the visitor's confirmation, and one combined status would make the admin screen lie in exactly the case an operator needs it to be honest.
  • sent means the transport accepted it, not that it arrived. The log, array and null mailers are reported as not configured rather than as sent, because reporting them as sent is a lie an operator cannot see through.

Stored failure detail is sanitized: connection strings and labelled secrets are redacted and the message is capped, because it is displayed to operators and a raw SMTP exception routinely carries credentials.

The business recipient resolves from the plugin setting, then the site's contact address, then the configured sender.

Reminders

Reminders are sent by an Artisan command, not a queue — CMS core ships no queued jobs, and introducing a queue dependency is a core decision this plugin has no business making. Add it to the host's cron:

php artisan webblocks-appointments:dispatch-reminders

Every few minutes is fine. Each appointment is considered exactly once: the outcome is recorded whatever it is, so a run costs nothing when there is nothing due, and an unreachable address is not retried on every tick. --dry-run reports what would go out. The lead is per site and zero switches reminders off.

Visitor cancellation

The confirmation and reminder emails carry a cancellation link. There is no account and no login — the appointment's cancel_token is the credential, which is the only workable design in a CMS with no public user system.

Three rules keep that safe and honest:

  • The GET only shows a confirmation page; the DELETE cancels. Mail clients, link scanners and corporate security appliances follow links in email unprompted, so cancelling on GET would mean bookings cancelled by spam filters.
  • An unknown token and a missing appointment look identical. Distinguishing them would make the page an oracle for guessing tokens.
  • An appointment that has already started cannot be cancelled here. Allowing it would rewrite a no-show into a cancellation after the fact.

When a visitor cancels, the business is notified and cancelled_by records that it was the visitor rather than an operator. If that notification fails the visitor never sees it — their cancellation is already committed, and the outcome is still recorded for the operator.

Time and correctness

Appointment instants are stored in UTC. Opening hours and their dated exceptions are stored as local wall clock, because "we open at 09:00" has to mean 09:00 on both sides of a daylight-saving transition. The site's clock comes from Site::resolvedTimezone(), never config('app.timezone').

The slot generator walks local wall-clock time so slots land on the marks a visitor expects. Two transition cases follow from that, and both are deliberate:

  • Spring forward. Wall-clock times inside the gap do not exist and are not offered.
  • Fall back. Wall-clock times in the repeated hour exist twice; the generator picks the earlier instant. PHP's own default is the later one, so this is an explicit choice rather than inherited behaviour.

Double booking is prevented three ways: a transaction with a locking overlap read (the real guard, and the only one that understands buffers and differing durations), a (resource_id, slot_lock) unique index as a database backstop that still allows a cancelled slot to be rebooked, and translation of the resulting integrity violation into an unavailable-slot response rather than a 500.

Operator screens

Appointments shows one day at a time, in the site's own clock, with status changes and manual entry. Services, Staff & Rooms and Opening Hours define what can be booked and when. Appointment settings holds the booking rules, and they are per site — two sites in one install do not have to agree about lead time or confirmation mode.

A service or resource that already has appointments is deactivated rather than deleted, so past bookings keep their history while no new ones can be made against it. Manual entry goes through the same booker as the public form, so it cannot double-book — but it deliberately skips the availability recheck, because an operator booking outside opening hours is making a decision, not evading a rule.

API and health checks

The bearer-token API exposes services, staff and rooms, weekly availability, dated exceptions, settings and read-only appointments under /webadmin/api/plugins/webblocks-appointments. Discover the enabled endpoints and required capabilities through the CMS API discovery and OpenAPI schema.

Plugin Health checks database setup, active services and resources, service assignments, opening hours, notification recipients, outbound mail and reminder scheduling.