Thirty-seven tables for a bonsai club
The Central Kentucky Bonsai Society is about seventy paying members in Lexington who meet on the third Saturday of the month to repot things, argue about wiring, and look closely at one another's trees. They asked for a website. What they needed was an operations system, and the gap between those two sentences is most of what this post is about.
It is live at ckbs.info. Every screenshot below is of a public page, captured signed out — the member side is not shown here, because everything on it belongs to somebody.
A website, and then everything a club actually does
The brief was a public face: who we are, when we meet, how to join. That part took a fortnight. Then the real list arrived, and none of it was web design.
Dues are collected on a third-party membership platform, so the roster lives somewhere else and has to be reconciled. The monthly newsletter is a PDF somebody emails around. The annual show needs entries, judging, scores, and members physically standing in the room for three days. Trees get photographed, repotted, and occasionally handed to a more experienced member for advice. Somebody has to be able to see whether the reminder email actually arrived.
Written down, that is not a brochure. It is thirty-seven tables.
The shape matters more than the count. Every cluster hangs off the same hub — a member — which means authorization is one question asked in eight different places, and the eight places must agree. That is the single largest source of bugs in an application like this, and it is why the member model, not the page, is where the rules live.
The stack
| Package | Version | What it is doing here |
|---|---|---|
| Laravel | 13 | PHP 8.4, SQLite, served by Herd locally |
| Livewire | 4 | 70 components — every interactive screen |
| Tailwind CSS | 4 | Tokens in @theme, no config file |
| Flowbite | 4 | Drawers, dropdowns, the mobile nav |
| Postmark | — | Two message streams: transactional and broadcast |
| Stripe | 20 | Event payments; dues are collected elsewhere |
| dompdf | 3 | Paper forms — sign-up sheets, entry guides, rosters |
| QR codes | 6 | Calendar subscription, printed on those forms |
169 Blade views, 66 migrations, 14 console commands, 165 test files. The interesting number is the last one: a club application is mostly rules about who may do what and when, and rules that nobody wrote a test for are rules that quietly stop being true.
A volunteer club has no staff to absorb a broken workflow. If the software gets it wrong, a person on a Saturday morning gets it wrong.
Two membership systems, one at a time
Dues run through an external membership platform the club already used and had no appetite for abandoning. That leaves two ways to know whether somebody has paid, and the temptation is to support both at once and let the code decide. That way lies a system where nobody can answer "is this person a member" without reading four files.
So there is one provider setting, and exactly one is live. The other path exists in the codebase and is switched off. The public tiers are just content on a page:
The reconciliation problem is the part worth flagging to anyone building this. An import turns the platform's roster into accounts, and the join key is the email address. That is a fact with consequences: a member who signs up here with a different address than they gave the platform is, as far as any query can tell, a different person. Saying so plainly, at the moment somebody is typing an address in, is worth more than any amount of matching logic behind it.
The bug that moved the entire application's clock
An officer types "6:30 PM" into the event form. The database stores 18:30:00. The site displays 6:30 PM. Self-consistent, obviously correct, shipped.
Then the calendar feed went out.
A bare Y-m-d H:i:s carries no offset, so those digits mean whatever the application says they mean — and the application said UTC while every human in the loop meant Eastern. Nothing displayed wrongly, because the same wrong label was applied on the way in and on the way out. The error only became visible at the one boundary where a real instant is required, which is the iCalendar feed, and it showed up as every subscriber's phone drawing the monthly meeting at 2:30 in the afternoon.
The fix was one line of configuration. What made it interesting is what else it silently repaired:
- Evening events stopped being marked "past" four hours before they began.
- A submission window closing at 23:59 stopped slamming shut at 7:59 PM.
- Between 8 PM and midnight, the care-log form stopped prefilling tomorrow's date.
- A member paying at 5 PM for a 6:30 PM workshop stopped being treated as paying for something already over.
Four bugs nobody had filed, all of them the same bug. The migration that came with the flip is the part to be careful about, because the schema splits in two and only one half must be touched:
| Kind of column | Example | Shift it? |
|---|---|---|
| An officer typed it | event start time | No — already club-local |
| The machine stamped it | row created at | Yes — a true instant |
| Date only | member since | No — never had a time |
Shifting the first row would have dragged every all-day event onto the previous evening. The rule that keeps it straight is one sentence: a stored datetime means what the clock on the wall in Lexington said.
Covering a show, hour by hour
The annual show runs three days, and the club has to have people in the room the whole time it is open: greeting visitors, answering questions, keeping hands off the trees, misting when the greenhouse gets away from you. Hours when the show is closed are covered by hired security. The schedule shows those too — a coverage story with a hole in it is not a coverage story.
Two things in that diagram cost more thought than they look like they should have.
The first is that a shift's claim check runs twice. Once to decide what the page draws, and once again inside the transaction that writes the signup, after re-reading the row under a lock. On an application with a hundred thousand users you would call that defending against a rare race. On a club schedule it is the ordinary case: the reminder email lands, forty people open it within the same ten minutes, and two of them tap the last spot on the popular Saturday block.
The second is that the refusal has to be able to explain itself. The claim method returns null for success and a sentence for failure, rather than a boolean, because "no" is useless to a member who cannot tell whether they are too late, not paid up, or looking at a block only security can cover.
Reminders then ride a four-rung ladder, one row per claimed shift:
| Rung | When | Why that one |
|---|---|---|
| 0 | 24 hours before | Late enough to be real, early enough to swap out |
| 1 | 7:00 AM that day | Skipped entirely for a shift starting before 8 |
| 2 | 1 hour before | Leave-the-house warning |
| 3 | 30 minutes before | The one that forces the schedule below |
That last row is why the reminder command runs every five minutes rather than hourly: a thirty-minute warning cannot ride an hourly schedule. The cadence is forced by the feature, not chosen for tidiness — which is the sort of thing worth writing down, because six months later it looks like somebody being careless with a cron entry.
And when several rungs come due at once — the server was down, the queue backed up — only the newest one sends, with the rest marked as passed. A member who wakes up to four identical reminders learns to ignore all of them.
Switches, and the thing a switch must never do
Not every club wants a forum. Nine features can be switched off from the environment, which lets a smaller site ship from exactly the same codebase rather than from a stripped-down branch that drifts away from the real one over eighteen months.
| Switch | What it takes away |
|---|---|
| blog, guides | Society news; care and technique write-ups |
| forum | Member discussion, categories and threads |
| shows | Entries, judging, and the docent schedule with them |
| advice | Handing a struggling tree to a better grower |
| newsletters | The monthly PDF and its archive |
| wallet | Membership cards for Apple and Google Wallet |
| email_log, audit_log | Two screens — and only the screens |
That last row is the one with a rule attached to it.
Turning off the mail log hides the page. It does not stop the listener writing rows, and it does not stop the bounce handler suppressing an address an officer blocked. A feature switch is allowed to take a page away; it is not allowed to open a hole in the audit trail, or to quietly resume mailing somebody who asked to be left alone. There is a test asserting exactly that, because it is the kind of rule that survives in a document and dies in a refactor.
The routes stay registered either way and answer with a themed 404. A link left behind in a template therefore degrades into "not found" rather than throwing — and a hidden feature looks like it was never built, rather than like something you are being kept out of.
Three traps, banked
A Blade directive written flush against the word in front of it is never compiled — but its closing tag is. Blade's compiler matches \B@\w+, so the opening tag renders as literal text while @endif compiles perfectly well, the chain unbalances, and the next branch throws a syntax error in a file that looks fine. Nothing else in the toolchain sees it: php -l never reads Blade, Pint formats PHP, and the Blade formatter will happily reindent around it. It shipped twice before earning a test that sweeps all 169 views.
A panel that is both the mobile menu and the desktop row must toggle a class, not a display style. Hiding it with an inline display: none beats the responsive utility that shows it on desktop, so the phone works and the desktop nav vanishes — easy to write, easy to ship, and invisible unless you resize the window.
Stacking a two-column layout is a content decision. When the grid collapses, source order wins, and source order was chosen for a wide screen. On this page the logo is deliberately promoted above the headline on a phone: the club's mark does more introducing than the sentence does when you have four inches to work with.
What it costs to keep running
Seven scheduled commands and one queue worker. Three of the seven run on a cadence forced by something they carry rather than by preference — the five-minute docent reminder above, an hourly sweep that finalises shows and chases unpaid event holds, and a weekly report whose look-ahead window has to match its own cadence or it starts skipping people.
That is the honest ongoing cost of a club site that does more than describe itself. Not the hosting — the fact that something has to be running on a Saturday at 6:30 in the morning to send a reminder that a person is relying on.
What I would tell the next club
The website is the cheap part. The expensive part is that a volunteer organisation runs on a hundred small agreements — who may enter a show, when the window closes, who gets told a shift is uncovered, what happens to a tree while it is being judged — and software makes every one of those explicit whether you were ready to be explicit or not.
That is uncomfortable and also the point. Half the questions in the feature docs for this project were answered by an officer, once, and are now written down somewhere that a future volunteer can find. The application is a side effect of writing the club's rules down.
Also: the trees are extremely good, and I have learned more about repotting than I expected from a Laravel project.