>set display
PHOSPHOR
MODE
BOOT SCREEN
SOUND
← BACK TO PROJECTS
LARAVEL · EXPO · POSTGRES · STRIPE · IN DEVELOPMENT

sixish

A social club for small local meetups. Four to eight people, a real place, and no feed of faces to scroll. A Laravel API, an Expo app and a staff console, built solo on my own time.

A table
Four to eight seats, staff approve every event
The rule
A member never browses members. Profiles unlock four hours before you meet
A seat
$0 to book, $13.50 held at the lock, $3.50 taken when you turn up
First market
Tampa Bay, starting in downtown St. Petersburg
Built with
Laravel 13, Postgres 16, Expo, Filament, Reverb, Stripe, Twilio, Cloudflare
Status
Sprint 9 of 9, pre-launch, closed source
Three sixish screens side by side: the event feed with an i'm free banner, an event detail for a wheel-throwing class showing seats taken and an age band, and the table card listing who is coming tonight
The feed, an event, and the table. Design boards from the canvas the app is built against.

Every app for meeting people locally is the same app. A grid of faces, a swipe, a chat that dies in four messages, and nobody ever leaves the house. The part that actually works, a small group of people at a table with something to do, is the part none of them will commit to, because a feed of profiles is easier to build and easier to sell ads against.

sixish commits to it. Staff approve an event at a real place, the app opens four to eight seats, you book one, and four hours before it starts the group chat opens and you find out who else is coming. That is the whole product. There is no browsing, no swiping, and no way to look up a person you did not sit with.

You never browse the members

That rule is the promise, so it lives in code rather than in the copy. GET /api/v1/events/{id} returns seat counts, an age band and a gender makeup, and never an attendee identity. The response is built through a dedicated resource that has no path to a profile at all, because the realistic way to break this is not a bad decision, it is a helpful ->load('registrations.profile') added a year from now by someone chasing an N+1.

So the test for it was written on the first day and it never comes out. The venue endpoints get the same rule and the same test. An identity leak here is not a bug report, it is the reason the product existed.

Identity unlocks on a schedule instead. A job opens the chat room four hours ahead, and the attendee cards appear with it: a first name, one line the member wrote, what they are looking for, and their tags. After the event the room stays open, and blocking someone closes it.

Three more screens: a questionnaire card asking what you do when the table is still going at 9pm, the event group chat with a check-in lead prompt, and a full-screen you are here confirmation
Seven questionnaire screens, the chat that opens four hours out, and the check-in that settles the hold.

The money exists to solve attendance

A table of six with two no-shows is not a smaller table, it is a bad night, and the four who did turn up are the ones who pay for it. Free RSVPs are how that happens, so a seat is not free, but it is not really a ticket either.

Booking costs nothing. Twenty four hours out the group locks and Stripe places a $13.50 manual capture hold. You turn up, the app takes $3.50, and the rest of the hold releases. The ladder either side of that is the whole design:

What you didWhat it costs
Turned up$3.50
Cancelled 72 hours aheadNothing
Cancelled inside 72 hours$8.50
Silently did not turn up$13.50
Told us why, and it was real$3.50

The last row is the one that matters. A member who says something keeps the price of turning up, which makes telling the group always cheaper than going quiet. Cars break and shifts get moved, and a policy that cannot hear that is a policy that loses the member instead of the seat.

Nothing charges a card on one signal

Which puts all the weight on knowing who was actually there, and there is no clean way to know. So attendance comes from three weak sources: a host tapping people in on a roster, a self check-in that has to be inside the venue geofence and inside the window, and the exit survey the next day, where you mark the faces you remember.

One class, ResolveAttendance, is the only thing in the codebase allowed to write attended or no_show. It runs twice. Forty five minutes in it settles the easy ones and refunds them the same night. When the survey closes it decides the rest, and it needs two signals to agree before it does anything:

The under_review branch is the point of the design. A wrong capture costs a member and a public complaint, and a missed one costs ten dollars, so the ambiguous cases are worth a human every time. Its test suite covers every combination of the four inputs, and it was written before the class was.

The survey ships two weeks before the charging does

Check-in and the resolver are sprint 7, and the code that captures money is sprint 8, and the split is deliberate. The plan is to run the resolver live while charging nobody, and read what it decides. The geofence radius will be wrong, or the survey response rate will be too low to lean on. Finding that out from the logs costs nothing. Finding it out from a member's bank statement costs the member.

Matching in the database, balance in PHP

Filling a table has hard filters and soft ones. The hard filters are set membership and arithmetic, so they run in one Postgres query: city, availability as a bitmask of seven days by four blocks, an age range read both directions, budget, travel radius against an approximate home pin through earthdistance, blocks in either direction, and anyone already seated. It comes back as the top fifty by tag overlap.

Ranking those fifty happens in PHP, because group balance is not a row-level question. Whether one more quiet person makes a good table depends on who already holds a seat, and the answer-agreement score centers each member's answers on their own mean before comparing them, which needs whole profiles rather than pairs of rows. Two indexed queries feed that pass. Writing it in SQL was possible and would have been unreadable within a month.

The questionnaire behind it is seven screens, and each one saves as you leave it, so a member who closes the app halfway through finds every answer again on any device. No single request decides that onboarding is finished. One method reads the whole record and decides, which is what keeps a half-answered profile out of the matching.

One repo, one deploy

PieceWhat it is
app/Laravel 13 on PHP 8.3: the API, the Inertia marketing and invite pages, the Filament staff console, Reverb, and the queued jobs on Horizon
mobile/The Expo app, TypeScript and NativeWind, on a development build rather than Expo Go
packages/The typed API client, the shared icon set, and the client types generated out of the PHP
workers/Two Cloudflare Workers: signed reads for member media out of Backblaze B2, and the public site with its waitlist form

The staff console is Filament rather than something custom, because day one needs a report queue, a refund tool, an event calendar and a photo review, and none of that is where the design effort belongs. React is for the surfaces members touch. The client types are generated from the Laravel data objects, since a hand written type in a payment flow drifts quietly and then costs money.

Member photos never come off the origin. Laravel signs a short lived URL, a Cloudflare Worker verifies the signature and streams the object out of B2, and the entitlement check that decides whether you may see a face at all is a single class with its own tests. Uploads get resized and stripped of EXIF on the way in, because a photo taken at home carries the coordinates of the member's house.

Where it is

Nine sprints, and I am in the last one. The API, the questionnaire, the media pipeline, events and the feed, the trust signals and the signup gate, Stripe and the fee ladder, the chat and the support inbox, check-in and the exit survey, and the no-show charging are all written and tested. What is left in sprint 9 is push notifications, the invite flow, the legal pages, the security checklist and the store submission.

That comes to about 65,000 lines of PHP and 30,000 of TypeScript behind roughly 1,374 tests, over 40 migrations. The gates run on every push, and CI runs the same commands, because a hook that only works on my machine is not a gate.

sixi.sh is live with the waitlist page on it, served by the Worker. The club opens in Tampa Bay, and signup is limited to it on purpose: a social club with four members in each of thirty cities is not a social club. Everyone outside the area lands on the waitlist, and where those pins cluster is what decides the second city.