A Rock Solid Laravel Web App

This is the production architecture behind ai-track.app, a SaaS product I run on Laravel, and a walkthrough of how every operational concern of a professional web app is handled: hosting, deploys, caching, queues, logging and the rest.

Plenty of Laravel content covers writing application code. Much less covers running one as a business, where the uptime, the deploys and the 2am error pages are yours. This is the operating stack I trust for that, and why each piece earns its place.

The Stack

  • Framework: Laravel 13 on PHP 8.5
  • Front end: Inertia v3 + Vue 3 (Composition API), Tailwind v4, shadcn-vue, compiled with Vite
  • Database: MySQL 9
  • Cache and queues: Redis
  • Hosting: Google Cloud Compute Engine, provisioned and managed by Laravel Forge
  • Billing: Paddle (merchant of record, so sales tax is their problem)

The theme across every choice: boring, first-party, and operable by one person. Every component is either maintained by the Laravel team or is infrastructure Google runs. There is no Kubernetes, no microservices, and no bespoke deploy tooling, because a well-tuned single application on solid hardware covers more scale than most SaaS products will ever see.

Hosting: Google Cloud Compute + Laravel Forge

Forge is the management layer, Google Cloud is the hardware. Forge provisions a GCE instance with a hardened baseline: Nginx, PHP-FPM, MySQL, Redis, UFW firewall with only 22/80/443 open, fail2ban, automatic security updates, and free SSL via Let’s Encrypt with auto-renewal.

Why GCE underneath instead of a commodity VPS: predictable CPU under sustained load, fast NVMe-backed disks, snapshot support at the infrastructure level, and a straight upgrade path. When the app needs more, I resize the instance or split MySQL onto its own machine, both of which Forge handles without re-architecting anything.

The division of labor matters: Forge never touches application logic, and I never SSH in to hand-edit server config. Server state lives in Forge where it is visible and reproducible. If the instance died, a replacement is an hour of Forge provisioning plus a database restore, not an archaeology project.

Deployments

Deploys are git-driven through Forge: push to the production branch, Forge pulls and runs the deploy script. The script is deliberately short:

cd /home/forge/ai-track.app
git pull origin $FORGE_SITE_BRANCH

$FORGE_COMPOSER install --no-dev --no-interaction --prefer-dist --optimize-autoloader

npm ci && npm run build

$FORGE_PHP artisan migrate --force

$FORGE_PHP artisan config:cache
$FORGE_PHP artisan route:cache
$FORGE_PHP artisan view:cache
$FORGE_PHP artisan queue:restart

$FORGE_PHP artisan octane:reload 2>/dev/null || sudo -S service php8.5-fpm reload

The details that make this production-grade rather than hobby-grade:

  • Migrations run on every deploy with --force, which means migrations must always be written to be safe on live data: additive first, destructive only after the code that depended on the old shape is gone.
  • Caches rebuild atomically as part of the deploy, never by hand. Config, routes and views are compiled, so production never pays the reflection cost.
  • queue:restart signals workers to finish their current job and reboot onto the new code. Skipping this is the classic Laravel bug: queues silently running last week’s code.
  • FPM reloads gracefully, finishing in-flight requests. Combined with the pull-then-build order, deploy downtime is effectively zero for a product at this scale; Envoyer-style symlink releases are the upgrade when even that window matters.

Rollback is git revert and push. Because migrations are additive, rolling code back never strands the schema.

Caching & Performance

Layers, from the metal up:

  • OPcache holds compiled PHP in memory; Forge configures it sensibly out of the box.
  • Laravel’s compiled caches (config, route, view) eliminate per-request bootstrapping work.
  • Redis for the application cache and sessions: computed dashboard aggregates, expensive API-derived data and rate limiter state live here with explicit TTLs. Sessions in Redis instead of the database keeps a high-chatter table out of MySQL entirely.
  • Query discipline over query caching: eager loading against N+1s, covering indexes for the hot paths, and pagination everywhere. A query cache in front of bad queries is a bandage; ai-track’s slowest endpoints got indexes, not cache.
  • HTTP caching for assets: Vite outputs hashed filenames, so static assets ship with immutable, year-long cache headers. HTML is never cached: it is a logged-in product, and Inertia responses are per-user.

Performance is measured, not assumed: slow query logging on MySQL, and Laravel’s query log reviewed in development with Laravel Boost whenever a page feels heavy. The rule of thumb that has served every app I have run: get the database right and the rest of the performance conversation gets short.

Queues & Background Jobs

Anything slower than ~100ms of real work that the user does not need to watch happens in a queued job: external API calls, report generation, webhook processing, email. Jobs live in app/Jobs/, queues run on Redis, and workers run as Forge daemons:

php artisan queue:work redis --queue=high,default --sleep=1 --tries=3 --max-time=3600

The operational details that matter:

  • Forge daemons are supervisor-backed, so a crashed worker restarts itself. Nobody is SSHing in to resurrect workers.
  • Named queues give priority lanes: user-facing work (receipt emails, exports) goes on high; batch and maintenance work rides default.
  • –max-time recycles workers hourly, which keeps slow memory leaks in long-running PHP processes from ever mattering.
  • Failed jobs land in the failed_jobs table, with a scheduled check that nags me if that table is non-empty. Retries are explicit decisions, not hope.
  • Webhooks are acknowledged instantly and processed async. Paddle’s webhook hits a thin controller that validates the signature, stores the payload, dispatches a job and returns 200. Billing state never depends on how fast my code runs inside someone else’s timeout.

Logging & Debugging

The stack log channel writes structured context, not prose: every log call carries the user ID, the relevant model IDs and the operation, so a report of “export failed” is answerable without reproduction. Daily log rotation keeps disk use flat.

For live tailing, php artisan pail streams application logs with filtering, which replaced my old habit of grepping log files over SSH. Uncaught exceptions get reported with full context, and the error page a user sees is generic while the log entry is specific.

The complement to logging is uptime monitoring from outside the box: an external check against a health endpoint that verifies the app boots, the database answers and Redis responds. Internal monitoring cannot tell you the server is down.

Scheduled Tasks

One cron entry on the server, installed by Forge, runs Laravel’s scheduler every minute. Everything else is code:

Schedule::command('reports:aggregate-daily')->dailyAt('02:00');
Schedule::command('queue:prune-failed --hours=168')->daily();
Schedule::command('cache:prune-stale-tags')->hourly();

Schedules in version control means every recurring behavior of the system is visible in one file, reviewed like any other code, and deployed like any other change. Overlap protection (withoutOverlapping) and background execution are one method call each, which is the kind of operational detail Laravel gets right for free.

Database & Backups

MySQL 9 on the same instance until the day it should not be, with the two protections that actually matter:

  • Nightly dumps shipped off the instance to cloud storage, retained on a rolling schedule. A backup on the same disk as the database is a wish, not a backup.
  • GCE disk snapshots as the coarse-grained second layer, because they restore a whole known-good machine fast.

Restores are tested by actually restoring, into a scratch database, on a schedule. The first time you test a restore should not be during an outage.

Security

  • Firewall closed to everything except SSH and HTTP(S); SSH is key-only with password auth disabled.
  • Automatic security patching at the OS level via unattended upgrades.
  • TLS everywhere with auto-renewing certificates; HSTS on.
  • Application auth through Fortify: rate-limited logins, hashed passwords, session hardening handled by the framework rather than hand-rolled.
  • Secrets live in the environment file on the server, managed through Forge, never in the repository.
  • Billing data never touches the server: Paddle hosts checkout, so no card numbers exist anywhere in my infrastructure.

The Frontend Build

Inertia v3 keeps the app a monolith with an SPA feel: Vue 3 pages, server-side routing, no separate API layer to version and secure. Vite builds hashed, split bundles during deploy, Wayfinder generates typed route helpers so the JavaScript never hardcodes a URL, and Tailwind v4 keeps the CSS payload proportional to what is actually used.

The operational benefit of the monolith is underrated: one deploy, one log stream, one auth system, one thing to monitor. Every architectural split you add doubles the surface you operate.

Takeaways

  • Forge + a serious cloud provider gives one person the operational posture of a platform team: provisioning, SSL, firewalls, daemons, crons and deploys, all reproducible.
  • Deploy scripts should be short, atomic and boring, with queue:restart as the step everyone forgets.
  • Redis earns its place three times over: cache, sessions and queues.
  • Fix queries before caching them; index before scaling.
  • Queue anything a user should not have to watch, and acknowledge webhooks before processing them.
  • Backups that have never been restored are theoretical. Monitoring that runs on the same box is too.

This is the whole production story for a revenue-generating SaaS, operated by one person, with deploys measured in seconds and incidents measured in months apart. Laravel’s ecosystem is what makes that math work.