Pitchbar is built for Laravel Cloud as the primary target — the stack is FrankenPHP + Postgres + Redis, all of which Laravel Cloud provisions natively. Self-hosting is supported but the operator owns more pieces.
infra/cloud.yaml in the repo describes the environments
and processes. The high-level shape:
us-east by default. Choose for proximity to your customers.crawl, index, and default queues.| Env | Purpose |
|---|---|
| preview | Per-PR ephemeral environments. Auto-spun on PR open, torn down on merge / close. |
| staging | Long-lived. Mirrors production config. Used for QA and pre-release verification. |
| production | The customer-facing environment. Releases gated on green CI + manual deploy. |
Starting point. Adjust based on traffic.
| Component | Size | Why |
|---|---|---|
| App (Octane) | 2 instances × 2 vCPU / 2 GB | Hot path is mostly I/O-bound on LLM streaming. Two instances for HA. |
| Worker (Horizon) | 2 instances × 2 vCPU / 2 GB | Indexing throughput. Scale on queue depth. |
| Reverb | 1 instance × 1 vCPU / 1 GB | WebSocket, sticky. |
| Postgres | 2 vCPU / 4 GB / 50 GB SSD | Comfortable until ~10M messages. |
| Redis | 1 GB | Sessions, queue, hot caches. |
The single most important sizing number, and the one that surprises
people. A visitor turn is a Server-Sent Events stream: the request
stays open, token by token, until the answer finishes. That request
holds one PHP process for the entire duration — an
Octane worker, or an FPM child, depending on how you serve the app.
So the number of visitors who can be mid-answer simultaneously is
exactly your process count (--workers under Octane,
pm.max_children under PHP-FPM) — nothing more. This is a
property of streaming, not a limitation of any one server.
Two consequences worth internalising:
workers ÷ average turn seconds.
Four workers against a 3.4-second turn is ~1.2 turns/second, no
matter how much CPU or RAM the box has.Card #511. Octane/FrankenPHP, LLM replaced by the built-in fake tuned to a realistic 3.4-second turn (2.0s to first token), so the figures isolate the application's ceiling from provider latency. N = simultaneous visitor turns; TTFT = time to first token.
| Workers | Throughput | TTFT p95 @ N=8 | TTFT p95 @ N=16 | TTFT p95 @ N=32 | TTFT p95 @ N=48 |
|---|---|---|---|---|---|
| 4 | 1.18 turns/s | 5.5s | 12.4s | 25.8s | 39.3s |
| 8 | 2.34 turns/s | 2.2s | 5.5s | 12.4s | 19.1s |
| 16 | 4.58 turns/s | 2.2s | 2.1s | 5.6s | 9.3s |
| 32 | 7.97 turns/s | 2.2s | 2.1s | 2.2s | 5.8s |
Scaling is linear — roughly 0.29 turns/second per worker
— and TTFT stays flat at ~2.1s for as long as N ≤ workers.
Past that, each additional full batch adds one turn's duration to the
wait. There is no cliff and no failure: across every run above,
zero requests errored. Overload degrades as a queue,
not as an outage, which is why a saturated instance shows up as
"the bot got slow", never as an error page.
Workers are cheap. Measured marginal cost is about 13 MB of RSS per additional worker on top of a ~135 MB fixed base — so 4 → 16 workers costs roughly 160 MB and quadruples capacity. Note that dividing total RSS by worker count badly overstates this, because most of the footprint is the fixed base, not per-worker arena.
Since the hot path is I/O-bound (the worker spends nearly all of its held time waiting on the LLM, not computing), worker count can safely exceed vCPU count. Reasonable defaults:
| Situation | --workers |
|---|---|
| Single small-business site, a few visitors at a time | 4–8 |
| Busy shop, or several agents on one box | 16 |
| Multi-tenant / SaaS resale | 32+, and add instances |
Where you raise it depends on how you serve the app:
--workers
flag on octane:start. Under PM2, edit the process args and
recreate it — pm2 delete <name> && pm2 start ecosystem.config.cjs.
A plain pm2 restart reuses the old arguments and silently
keeps the previous worker count.pm.max_children in your pool config. An SSE
stream occupies one FPM child exactly as it occupies one Octane worker.
Raise it and reload php-fpm.
Whichever server you run, also make sure the request timeout in front of PHP
(nginx proxy_read_timeout, Apache ProxyTimeout,
max_execution_time) is longer than your slowest turn, and that
response buffering is off for the widget's stream endpoint. A proxy that
buffers SSE will hold the whole answer and deliver it in one lump, which
reads to the visitor as a long hang followed by a wall of text.
RAG_RERANK_TIMEOUT_SECONDS) and prompt size all buy
concurrency, not just latency.
Measured against a live Cloudflare Vectorize account, one
IndexDocumentJob used to cost about 4.5 seconds:
| Step | Was | Now |
|---|---|---|
ensureCollection — 1 GET + 3 metadata-index creates | 3,423 ms, every job | once per worker generation |
deleteByFilter with nothing to delete | 700 ms | skipped on a first index |
embed, 1 chunk | 181 ms | unchanged |
embed, 20 chunks | 523 ms | unchanged |
Three quarters of every job was one question — "does this index exist
at this dimension?" — whose answer cannot change while a run is in
flight, asked once per document. It is now memoised per (index,
dimension), in the process and in the shared cache, and forgotten
when vector:rebuild-index drops the index. The key
carries the dimension, so changing the embedding model still triggers
a real check and the dimension-mismatch guard is intact. The delete
pass is skipped only when the document has no chunk rows — rows are
always written before their vectors are upserted, so "no rows" proves
"no vectors", and any document indexed before still has its old
vectors removed first.
If your install is slow to index, measure before adding workers: the bottleneck is usually a repeated round-trip, not a lack of CPU.
Everything above sizes the workers that answer visitors. The
background queue is its own pool, and it starves in a way that is
easy to misread: IndexDocumentJob is one embedding
round-trip per document (four to five seconds, nearly all network
wait), so a customer who adds a source with a few thousand documents
creates a multi-hour queue. A single worker takes its queues in
strict priority, so everything listed after index —
every other agent's page crawls, and the heartbeat job the health
monitor reads — waits behind that. The monitor then reports
"queue has not consumed jobs", which reads as a dead worker when the
worker is simply busy. Check
pm2 logs pitchbar-queue --lines 20 --nostream before
restarting anything: lines scrolling past mean the fix is throughput.
ecosystem.config.cjs therefore ships two pools, both
environment-tunable:
| Variable | Pool | Default |
|---|---|---|
PITCHBAR_QUEUE_WORKERS | General — analytics, default, crawl, index | 2 |
PITCHBAR_INDEX_WORKERS | Embedding only — index | 3 |
Each PHP worker costs roughly 75 MB. On a 1–2 vCPU VPS set both to 1;
on a bigger box raise PITCHBAR_INDEX_WORKERS first, since
embedding is network-bound and parallelises almost linearly — five
workers measured 65 jobs/minute against 13 for one. crawl
deliberately stays with the general workers: headless-browser
rendering services enforce their own concurrency limits, and
CrawlSourceJob already staggers page dispatch for that
reason, so widening it there buys errors rather than speed.
The database queue driver reserves each job atomically, so parallel
workers never run the same job twice. As with Octane's
--workers, a plain pm2 restart will not
apply a new count: pm2 delete pitchbar-queue pitchbar-index
&& pm2 start ecosystem.config.cjs && pm2 save.
You typically need:
app.pitchbar.com for the customer / admin app.cdn.pitchbar.com serving /widget/widget.js. The bundle has a content-hash query param, so aggressive caching is safe.realtime.pitchbar.com if you split the WebSocket process onto its own host.
GitHub Actions workflows under .github/workflows/:
tests.yml — PHP setup + Composer + Pest suite (with fakes, no network).lint.yml — Pint, ESLint, TypeScript tsc --noEmit.widget.yml — widget bundle build + size budget check.Deploys are gated on green CI; the actual deploy step is configured on Laravel Cloud (or your hosting equivalent), not in the workflow files.
Laravel Cloud runs php artisan migrate --force on every
deploy. Migrations should be backwards-compatible — a deploy that adds
a NOT NULL column to a populated table needs a two-step:
Same goes for renames and drops — never destructive in a single deploy.
chunks table by re-dispatching IndexDocumentJob for every document. php artisan pitchbar:audit-vectors reports drift between the chunks table and the live vector store; if you need to repair, dispatch the job per row.APP_KEY separately (it's the master for app_settings encryption).Laravel Cloud keeps the previous release for instant rollback. For schema-incompatible rollbacks (rare), restore from the latest snapshot.
The same Docker setup that powers docker-compose.yml works
for production with a few additions:
The composer run dev shortcut starts everything locally
(Octane, queue worker, Reverb, vite) for development.
This section only applies when you serve with
FrankenPHP/Octane. Apache, nginx + PHP-FPM,
LiteSpeed, and php artisan serve all ignore the
repository's php.ini and use your system's own PHP
configuration — there, just make sure memory_limit has
headroom and log_errors is on, and skip the rest of this
section.
The FrankenPHP static binary contains its own PHP (independent of any
system PHP; responses carry that version in X-Powered-By)
and it bundles no php.ini. With none present,
php_ini_loaded_file() is empty and PHP runs on its
compile-time defaults — two of which are actively dangerous under
Octane:
| Directive | Bare default | Why it bites |
|---|---|---|
memory_limit |
128M |
Octane boots the app once and a worker accumulates memory across requests, settling near 105-125 MB. Whichever worker crosses the ceiling dies mid-request. |
log_errors |
0 |
Every fatal is silently discarded — nothing in laravel.log, nothing in the process manager's stdout/stderr logs, nothing anywhere. |
upload_max_filesize |
2M |
Caps each knowledge-source file. Well below the 50 MB the upload form offers, so documents are refused with no obvious cause. |
post_max_size |
8M |
Caps the whole multipart body. A file cannot arrive inside a request smaller than itself, so this bounds a single upload just as tightly. |
Together those two produce a failure that is almost impossible to
trace: HTTP 500 with a zero-byte body,
answered in milliseconds, recovering with no intervention, and leaving
no log line at all. The supervising process never restarts (only the
worker thread dies), so the process table looks healthy too.
One ceiling sits above PHP and cannot be raised from
php.ini: the reverse proxy's own request-body limit.
It rejects an oversized upload with 413 Request Entity Too
Large before PHP is ever invoked, so the application never
learns it happened.
# nginx — inside the server block, then: nginx -t && systemctl reload nginx
client_max_body_size 64M;
# Apache — vhost or .htaccess (bytes)
LimitRequestBody 67108864
Leave it at the default and the upload form's own limit is
irrelevant: every document over roughly 1 MB fails. The admin UI
recognises a 413 and says which setting to raise, but it cannot
change it for you. Keep this number at or above
post_max_size.
The repository therefore ships a php.ini at the project
root, which is where FrankenPHP looks. Verify a change
before restarting anything:
./frankenphp php-cli -r 'echo php_ini_loaded_file(), PHP_EOL, ini_get("memory_limit"), PHP_EOL;'
Fatals land in storage/logs/php-fatal.log. The PHP CLI
does not search the working directory for a php.ini, so this file
changes nothing for php artisan, Herd, or the test suite.
tests/Feature/Ops/PhpIniGuardTest.php fails the build if
the file is deleted or its directives are weakened — the regression is
otherwise invisible, since removing it breaks no test and no healthy
box.
Turning error logging on immediately exposed what the 500s actually
were, and it was not memory. An exception escaping an Octane request
makes Laravel render its 500 page; building that response constructs
an Illuminate\Http\Response, whose constructor assigns
$headers directly — which
symfony/http-foundation 8.1 turned into a property hook
that fires a deprecation on every assignment. Laravel then tries to
log that deprecation against a container Octane has already flushed:
#11 HandleExceptions.php(105) LogManager->channel('deprecations')
#5 Application->make('config')
#0 ReflectionException: Class "config" does not exist -> FATAL
The result is a zero-byte 500 and the loss of the original
exception, which is never rendered and never logged. Laravel's own
three guards all miss it: Application::flush() does not
reset hasBeenBootstrapped, and
make(LogManager::class) succeeds by reflection because
LogManager is a concrete class, so the surrounding try/catch never
fires.
App\Support\DeprecationGuard wraps the error handler and
drops deprecations raised while config is unresolvable,
delegating everything else untouched. It keys off container state
rather than any package version, so a future Laravel or Symfony bump
cannot reopen the hole. It is installed from
AppServiceProvider::boot() — after
HandleExceptions has registered its own handler during
bootstrapWith() — and is idempotent, because PHP chains
error handlers and Octane boots providers on every request.
false, and Laravel's handleError() returns
void. Casting the delegated result to bool turns every
deprecation into a second write to error_log.
octane:reload does not apply a php.ini change.
The ini is read once, when the process starts. A reload swaps the
application code inside the running FrankenPHP process and leaves the
old ini in force, so the fix looks applied while nothing changed.
Restart the Octane process itself (systemd / PM2 / supervisor).
When in-cluster scheduling isn't available (cPanel shared hosting,
DIY VPS without systemd, Laravel Cloud's preview environments), an
external Cloudflare Worker can drive the queue every 60 seconds by
POSTing /api/v1/internal/queue-tick with the
INTERNAL_QUEUE_TOKEN bearer secret. The endpoint
invokes php artisan queue:tick which spawns one
queue:work --once --stop-when-empty pass with these
defaults:
--max-time=55 — the loop exits before the 60-second tick boundary so consecutive ticks don't pile up.--job-timeout=120 — individual jobs (mostly CrawlPageJob / IndexDocumentJob) get a 2-minute ceiling.analytics,default,index,crawl — strict priority, light queues first. analytics is mandatory: it carries the after-stream telemetry and persistence jobs (widget events, turn persistence, usage counters). Drop it and the Widget Monitor stays empty while conversation history silently never saves.
Build + deploy the Worker via
php artisan pitchbar:deploy-cron-worker. The Worker
body is templated from WorkerDeployer and ships with
the tick parameters baked in. Rotate
INTERNAL_QUEUE_TOKEN after deploy.
CrawlPageJob retries up to 3 times
with backoff [30, 90, 180] seconds. The retry path
branches on failure class:
release(60) without burning a retry slot. Every fan-out page tends to hit the same 429 wave; the shared wait is productive.$this->fail() immediately. Without this, every dead URL burned the full 3-retry budget and produced a generic MaxAttemptsExceededException in the logs.
Per-job timeout is 90 seconds; failOnTimeout=true so
a SIGTERM on timeout still runs the failed() callback
and flips the Source row to failed with a
customer-readable error.