Architecture
Stack & layout
Pitchbar is a single Laravel codebase with two frontends. Backend is Laravel 13 + Octane on PHP 8.3+; admin UI is Inertia v3 + React 19; visitor widget is Preact β€50KB. AI stack defaults to Cloudflare (Workers AI + Vectorize + Browser Rendering) with OpenAI + Qdrant as fallback.
The stack
| Layer | Tech |
|---|---|
| App framework | Laravel 13 (PHP 8.3+) |
| Server | Laravel Octane on FrankenPHP |
| Realtime | Laravel Reverb (WebSocket) |
| Queue | Laravel Horizon on Redis |
| Auth | Laravel Fortify (sessions, 2FA, password reset) + Sanctum (API tokens) |
| Billing | Laravel Cashier (Stripe) |
| Database | Postgres 16 |
| Cache / sessions | Redis 7 |
| Admin frontend | Inertia v3 + React 19, Tailwind v4, shadcn/ui (Radix), Vite, strict TS |
| Typed routes | Wayfinder (TS bindings to Laravel routes) |
| Visitor widget | Preact 10 (aliased as React) + Vite + selective Tailwind v4 |
| LLM (preferred) | Cloudflare Workers AI β Llama 3.3 70B + bge-base-en-v1.5 |
| LLM (fallback) | OpenAI gpt-4o-mini + text-embedding-3-small (and OpenRouter as a router) |
| Vector store (preferred) | Cloudflare Vectorize |
| Vector store (fallback) | Qdrant (HTTP client) |
| Crawler (preferred) | Cloudflare Browser Rendering |
| Crawler (fallback) | Browserless β plain HTTP |
| Object storage | Cloudflare R2 (S3-compatible) |
| Hosting | Laravel Cloud |
| Observability | Sentry + OpenTelemetry β Honeycomb / Grafana Cloud |
Repository layout
One Laravel app at the repo root. The admin frontend ships as Inertia pages inside the same app; the visitor widget is a second isolated Vite build.
pitchbar/ β Laravel app
βββ app/
β βββ Actions/Fortify/ β Fortify hooks (CreateNewUser, etc.)
β βββ Concerns/ β BelongsToWorkspace, BelongsToAgent traits
β βββ Http/
β β βββ Controllers/Admin/ β customer + admin Inertia controllers
β β βββ Controllers/Widget/ β /api/v1/widget/* (visitor-side, JWT)
β β βββ Middleware/
β βββ Models/ β Workspace, Agent, Conversation, Plan, β¦
β βββ Services/
β β βββ Rag/ β Retriever, Chunker, PromptBuilder, CuratedAnswerMatcher
β β βββ Llm/ β OpenAiHttpClient, WorkersAiClient, Fakes
β β βββ Vector/ β VectorizeClient, QdrantHttpClient
β β βββ Crawl/ β CloudflareBrowserClient, AutoIndexPageVisit, PlainHttpCrawler
β β βββ Triggers/ β CtaSelector, LeadIntentDetector
β β βββ Analytics/ β EventStore (analytics rollups + gap detection ride in app/Jobs/Analytics)
β β βββ Billing/ β StripeProductSync, MeteredBilling, PayPalClient, RazorpayClient
β β βββ Tools/ β ToolRegistry + EscalateToHumanTool (Phase 2)
β β βββ Vertical/ β VerticalPresetRegistry + 7 preset classes
β β βββ I18n/ β LocaleResolver, LocaleCatalog (132 languages)
β β βββ Widget/ β WidgetJwt, WidgetCopy, InlineBlockParser
β βββ Jobs/Crawl/ β CrawlSourceJob, CrawlPageJob, IndexDocumentJob
β βββ Jobs/Analytics/ β DetectGapJob (post-stream gap detection)
β βββ Events/ β TokenStreamed, TurnCompleted, TurnFailed
βββ resources/
β βββ js/ β admin Inertia (default Vite build)
β β βββ pages/
β β βββ components/
β β βββ β¦
β βββ widget/ β visitor widget (separate Vite build)
β βββ views/ β Blade (Inertia root + marketing + docs)
β βββ css/app.css β Tailwind v4 entry
βββ routes/
β βββ web.php
β βββ api.php
β βββ channels.php
βββ database/{migrations,factories,seeders}
βββ tests/{Feature,Unit,Browser}
βββ docs/PLAN.md β full engineering plan
βββ public/widget/ β built widget bundle
Two frontends, one backend
The admin and customer surfaces are the same Inertia app β same Vite build, same component library. The roles are separated by route group and middleware, not by codebase. This keeps a single source of truth for design tokens, routing helpers, and authentication state.
The visitor widget is the opposite β it intentionally shares
nothing with the admin code. It can't import from
resources/js/; it has its own Vite config; it has its own
router (just a Preact component tree) and its own state. The size
budget is a hard 50KB gzipped β admin features cannot bleed in.
Reverb & WebSocket
Reverb runs as a separate process and powers:
- Inbox live updates β operators see new messages as they arrive.
- Human takeover events β the visitor's widget gets a "human is here" event when an operator claims the conversation.
- Operator presence β Available / Away states sync across team members.
Channels are private by default β the widget joins
conversation.{id} using its JWT, and the operator app
joins workspace.{id} using its session.
Cloudflare one-bill mode
Set CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN
and the LLM, vector store, and crawler all auto-bind to Cloudflare.
Total external infra cost: $5/month Workers Paid + per-request usage.
Replace any one piece (e.g. swap Vectorize for Qdrant by setting
QDRANT_URL) and the binding shifts.
Multi-tenant isolation
Every tenant-scoped query is filtered by the
BelongsToWorkspace trait's global scope. Crossing the
boundary requires an explicit withoutWorkspaceScope() with
a justifying comment. There's a regression test that fails the build
if a model with a workspace_id column doesn't use the
trait. See Multi-tenancy.