Operate
Deployment
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.
Laravel Cloud
infra/cloud.yaml in the repo describes the environments
and processes. The high-level shape:
- Region:
us-eastby default. Choose for proximity to your customers. - App process: FrankenPHP, Octane mode. Auto-scaled.
- Worker process: Horizon, dedicated to
crawl,index, anddefaultqueues. - Reverb process: persistent WebSocket server.
- Postgres: 16, with daily backups.
- Redis: 7, persistent.
Environments
| 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. |
Compute sizing for v1 launch
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. |
Domains
You typically need:
- Primary domain —
app.pitchbar.comfor the customer / admin app. - Widget domain — same or a separate
cdn.pitchbar.comserving/widget/widget.js. The bundle has a content-hash query param, so aggressive caching is safe. - Reverb domain —
realtime.pitchbar.comif you split the WebSocket process onto its own host.
CI/CD
GitHub Actions workflows under .github/workflows/:
tests.yml— PHP setup + Composer + Pest suite (with fakes, no network).lint.yml— Pint, ESLint, TypeScripttsc --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.
Migrations
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:
- Deploy 1: add the column nullable, backfill, app code starts writing it.
- Deploy 2: change the column to NOT NULL.
Same goes for renames and drops — never destructive in a single deploy.
Backups
- Postgres — daily snapshots, retained 30 days. Point-in-time recovery enabled.
- Vector store — Cloudflare Vectorize / Qdrant don't have a built-in backup; rebuild from the
chunkstable by re-dispatchingIndexDocumentJobfor every document.php artisan pitchbar:audit-vectorsreports drift between the chunks table and the live vector store; if you need to repair, dispatch the job per row. - R2 / object storage — versioning enabled.
- App secrets — Laravel Cloud's secret store is encrypted; back up
APP_KEYseparately (it's the master for app_settings encryption).
Rollback
Laravel Cloud keeps the previous release for instant rollback. For schema-incompatible rollbacks (rare), restore from the latest snapshot.
Self-hosting
The same Docker setup that powers docker-compose.yml works
for production with a few additions:
- Reverse proxy (Caddy or Nginx) terminating TLS in front of FrankenPHP.
- Managed Postgres + Redis (or self-managed with HA replicas).
- Horizon as a long-running service, monitored by systemd / a process manager.
- Reverb as its own process.
- Sentry / OTEL collector running locally or pointing at a SaaS.
The composer run dev shortcut starts everything locally
(Octane, queue worker, Reverb, vite) for development.
php.ini is mandatory — FrankenPHP ships without one
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. |
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.
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.
Why a deprecation could take down a response
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).
Queue worker tick from a Cloudflare Worker cron
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.- Queues processed:
analytics,default,index,crawl— strict priority, light queues first.analyticsis 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.
Crawler reliability
CrawlPageJob retries up to 3 times
with backoff [30, 90, 180] seconds. The retry path
branches on failure class:
- Rate-limit (429) —
release(60)without burning a retry slot. Every fan-out page tends to hit the same 429 wave; the shared wait is productive. - Permanent failures — curl DNS errors (6, 7), connection refused, malformed URL, HTTP 400 / 401 / 403 / 404 / 410 / 451 — call
$this->fail()immediately. Without this, every dead URL burned the full 3-retry budget and produced a genericMaxAttemptsExceededExceptionin the logs. - Transient (5xx, network blip) — normal retry with backoff.
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.