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.