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:

Environments

EnvPurpose
previewPer-PR ephemeral environments. Auto-spun on PR open, torn down on merge / close.
stagingLong-lived. Mirrors production config. Used for QA and pre-release verification.
productionThe customer-facing environment. Releases gated on green CI + manual deploy.

Compute sizing for v1 launch

Starting point. Adjust based on traffic.

ComponentSizeWhy
App (Octane)2 instances × 2 vCPU / 2 GBHot path is mostly I/O-bound on LLM streaming. Two instances for HA.
Worker (Horizon)2 instances × 2 vCPU / 2 GBIndexing throughput. Scale on queue depth.
Reverb1 instance × 1 vCPU / 1 GBWebSocket, sticky.
Postgres2 vCPU / 4 GB / 50 GB SSDComfortable until ~10M messages.
Redis1 GBSessions, queue, hot caches.

Domains

You typically need:

CI/CD

GitHub Actions workflows under .github/workflows/:

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:

  1. Deploy 1: add the column nullable, backfill, app code starts writing it.
  2. Deploy 2: change the column to NOT NULL.

Same goes for renames and drops — never destructive in a single deploy.

Backups

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:

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:

DirectiveBare defaultWhy 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.

Forward the delegated return value verbatim. PHP runs its own handler on top only when a handler returns exactly 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:

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:

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.