131 STUDIOS
Home / Blog / How I Built Tapboard; a digital menu boa
Laravel

How I Built Tapboard; a digital menu board SaaS

The full tech stack behind Tapboard, a digital menu board SaaS.

R
Robert Fountain
Founder ยท 131 Studios
Published
Jun 12, 2026
Read time
8 min

Tapboard puts digital menu boards on TVs in restaurants. A business owner builds their menu in a web dashboard (or syncs it directly from their Square POS), and it appears on Fire TV displays in their restaurant or store. Every choice in this stack was filtered through one question: can a single developer build it, ship it, and maintain it two years from now? Here is what survived that filter, and what I deliberately left out.

The boring core: Laravel, PHP 8.4, MySQL

The backend is Laravel on PHP 8.4 with MySQL. No microservices, no serverless functions, no event sourcing. A monolith on a framework I know deeply.

This is not a compromise. For a solo founder, the framework you know is worth more than the framework that benchmarks well on Hacker News. Laravel gives me authentication, queues, scheduled jobs, billing (via Cashier and Stripe), file storage, and mail out of the box. Every one of those is a week I did not spend writing plumbing.

A few rules I hold myself to, because nobody else is reviewing my code:

  • declare(strict_types=1) in every PHP file. Typed properties, explicit return types, no implicit mixed.

  • Validation lives in Form Request classes, never in controllers.

  • Business logic lives in service classes (SquareCatalogService, ManifestBuilderService). Controllers stay thin.

  • No repository pattern. Eloquent is the data layer. Adding an abstraction on top of an abstraction is how solo projects die.

Frontend: Livewire 4 and Alpine.js, not a SPA

The dashboard is Blade, Livewire 4, and Alpine.js. No React, no Vue, no separate API layer.

The honest reason is that a SPA means maintaining two applications. A frontend with its own build pipeline, state management, and deployment story, plus an API to feed it. Livewire lets me write stateful, reactive UI (menu editors, drag-to-reorder item lists, real-time form feedback) in PHP, in the same codebase, tested with the same test suite.

The tradeoff is real. Livewire round-trips to the server for interactions a SPA would handle locally. For a B2B dashboard where users edit a menu a few times a week, that latency is invisible. If I were building a collaborative editor or anything with sub-100ms interaction requirements, this would be the wrong call. I am not, so it is the right one.

Alpine handles the purely local stuff: dropdowns, modals, toggles. The rule is simple. If it touches data, it is Livewire. If it is just UI state, it is Alpine.

The interesting part: how a menu gets to a TV

This is where the stack stops being a standard Laravel tutorial.

When a user publishes a menu board, the backend does not serve it live to the displays. Instead, a publishing pipeline renders the entire board to static HTML, paginates it for the screen, and uploads the result to S3 behind CloudFront, along with a versioned manifest that describes what should be displayed.

The TV app polls for manifest changes. When the version bumps, it pulls the new pre-rendered content.

Yes, polling. Not websockets, not server-sent events. I made that choice deliberately, for three reasons:

  1. Restaurant wifi is hostile. TVs sit behind cheap routers, captive portals, and networks that drop long-lived connections without telling anyone. A websocket that silently dies is a stale menu nobody notices until lunch rush. A poll loop that fires at each interval either succeeds or fails loudly and retries.

  2. The displays do not depend on my app server. Once content is published to S3 and CloudFront, a display can lose contact with the Laravel backend entirely and keep serving its menu. If my EC2 instance goes down at 7 pm on a Friday, every screen in every restaurant keeps working. For a one-person company with no on-call rotation, that property is worth more than sub-second update latency.

  3. Menus do not change every second. A price update propagating in thirty seconds instead of thirty milliseconds is not a problem anyone has. Engineering for latency nobody needs is how you end up maintaining a Redis pub/sub cluster for a menu board.

The menu templates themselves are Blade packages served from S3 through a custom view finder, so I can ship new board designs by publishing a versioned template package, without deploying the application.

The TV app: Kotlin and Jetpack Compose

The display app is native Kotlin with Jetpack Compose, targeting Android TV and Fire TV. It is the one part of the stack outside my Laravel comfort zone, and I considered wrapping a WebView and calling it a day.

I went native because the app's whole job is to be a reliable kiosk. It needs to boot with the TV, survive for weeks without intervention, recover from network loss, report a heartbeat so I know each device is alive, and handle pairing. That is service-and-lifecycle work where a native app earns its keep. The actual menu rendering is the pre-rendered HTML from the publish pipeline, so the hard visual work still happens in Blade, where I am fast.

Square integration

Tapboard connects to Square via OAuth and syncs the merchant's catalog, so menu items and prices come straight from the POS instead of being maintained twice. Two rules from this integration that apply to any third-party sync:

  • The sync runs as queued jobs, never inline in a request. POS APIs are slow and occasionally flaky, and a user clicking "sync" should not wait on Square's response time.

  • Webhook signatures get verified before any payload is trusted. Always.

More POS integrations (Clover and others) follow the same service-class pattern, which is exactly why the business logic lives in App\Services\Integrations and not in controllers.

Testing: Pest, Pint, and mutation testing

The test suite is Pest, with feature tests covering every controller action and Livewire component, and unit tests for the service classes. Square API calls are mocked; tests never touch a live API.

The less common choice: Infection for mutation testing. When you work alone, nobody catches the test that asserts nothing. Mutation testing mutates your code and checks whether the suite notices. It is slow, so it does not run on every push, but it has caught real holes in coverage that line-coverage numbers hid.

Pint handles formatting so I never spend a thought on it.

Infrastructure: GitHub Actions, Forge, AWS

Deployment is GitHub Actions running tests and lint on every push, then Laravel Forge deploying to an EC2 instance on merge. Merges to develop deploy to staging, merges to main deploy to production. S3 and CloudFront serve the published board content and assets. Sentry catches errors, Mailgun sends mail.

There is no Kubernetes here, and there never will be. Forge plus a well-sized EC2 instance handles this workload with capacity to spare, and I can understand every piece of it at 2am.

What I would tell another solo founder

The pattern across every layer of this stack is the same: spend novelty where the product needs it, and nowhere else.

The publishing pipeline and the polling architecture are genuinely unusual, because reliability on a restaurant TV is the product. Everything else (the monolith, Livewire, MySQL, Forge) is aggressively conventional, because conventional is what one person can carry.

Your stack does not need to be impressive. It needs to be small enough to fit in your head, boring enough to still work next year, and clever only in the one place your product demands it. Figure out where that one place is, and spend your weirdness budget there.

Need help with your next project?

We build custom websites and applications for businesses ready to grow online.

Book a free consult