Your booking widget is where your leads leave your database

This is me

This is me

For years my answer to "let people book a call" was a Calendly embed. Paste the iframe, ship, move on. It works, and I would still recommend it to most people.

What eventually bothered me was not the tool. It was noticing what the iframe actually is in the flow of an app: it is the point where a lead (a name, an email, a phone number, a sentence about what they want) crosses out of my database and into someone else's, at the exact moment it is most valuable. To do anything with it afterwards I have to fetch it back through an API or a CSV.

The visual seam was the other half. On a landing page I had spent real time designing, the booking step was the one rectangle that visibly came from somewhere else: different type, different spacing, a logo that was not mine.

Both problems have the same shape. A scheduler is not a service you consume; it is a table plus a form plus some arithmetic about time. The service part is what you are paying for, and it is the smallest part.

Availability is config, not a settings screen

The first decision was where the hours live, and it is the one that most obviously separates a self-hosted scheduler from a SaaS.

A hosted tool has to put availability in a database behind an admin UI, because its users are not developers and it cannot ship them a file. That constraint does not apply to me, and inheriting it would mean building a settings screen, its validation, its permissions, and a migration, so that I can click through a form to express something I could have written in six lines.

'demo' => [
    'duration' => 30,
    'buffer' => 0,
    'days_ahead' => 21,
    'min_notice' => 120,
    'availability' => [
        1 => [['10:00', '14:00'], ['16:00', '18:00']],
        5 => [['10:00', '14:00']],
    ],
],

Config means my hours are in version control, they deploy with the app, they differ per environment for free, and reviewing a change to them is reading a diff. No admin screen exists to build, secure or maintain.

The trade is real and worth stating: changing availability is a deploy. For an app where non-technical staff manage their own calendars this is the wrong architecture and you want the database. For a developer's own booking page it is strictly better.

A consequence I made deliberately loud rather than forgiving: the event prop is validated with abort_unless on mount, so an undefined event key 404s instead of rendering an empty widget. An empty booking form on a landing page is a silent lost lead; a 404 in staging is a bug you fix in one minute.

Store UTC, keep the zone you showed

Timezones are where booking widgets quietly fall apart, so this got the most attention of anything in the package.

There are three zones in play: mine, the visitor's, and whatever the server happens to be set to. The naive implementations pick one and lose. Store local time and a DST boundary silently shifts every future booking. Store UTC alone and you know when but not what the visitor was looking at, so when someone says "I booked 3pm" you cannot tell whether they meant theirs or yours.

So availability is authored in the organiser's zone, the visitor's zone is detected client side from Intl.DateTimeFormat().resolvedOptions().timeZone, slots are rendered in the visitor's zone, and the row stores UTC plus the visitor's timezone.

Keeping that second column is the part I would defend hardest. It costs one varchar and it turns every later question into a conversion instead of a reconstruction. Without it, resolving "which 3pm did they mean" requires guessing what their browser reported months ago.

Two layers against double-booking

Free slots are availability minus existing bookings, which is correct right up until two people load the same page and both click the last Tuesday slot.

Checking availability before writing does not fix it: between the check and the insert there is a window, and under exactly the load that matters (a popular slot), that window is when both requests are inside it.

So there are two layers. A unique (event_type, starts_at) index, which is the one that actually guarantees correctness because the database is the only thing that can, and a re-check at confirm time, which exists to turn the collision into a readable "that slot just went" instead of a constraint violation.

The re-check alone would be a race. The index alone would be correct and rude. You want both, and it is worth being clear about which one is the guarantee and which one is the manners.

The leads are yours

They land in wireschedule_bookings, queryable with the same tools as everything else:

Booking::ofType('demo')->confirmed()->upcoming()->orderBy('starts_at')->get();

with a polymorphic booker relation when the person was logged in, and a wireschedule-booked browser event so the app can react.

That is the actual return on self-hosting. Not the widget matching the theme, though it does. It is that a booking is a row you can join to your users, filter by your tenants, and follow up from your own code.

Config format, the timezone behaviour, model scopes and theming are on the Wireschedule page. Source on GitHub, package on Packagist.