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.