Laravel is a joy to build in and fiddly to run well once Forge, a single VPS, or a shared host stops being enough. This is the senior-engineer playbook for putting a production Laravel/PHP app on AWS: the target architecture (PHP-FPM + nginx on ECS/Fargate or App Runner, RDS/Aurora MySQL, ElastiCache for cache/sessions/queues, S3 via Flysystem, behind an ALB), Horizon workers and the scheduler done the AWS way, .env → Secrets Manager, the MySQL cutover, and the gotchas. A MAP-funded AWS partner can run the whole thing — often at little-to-no cost to you.
Most Laravel apps start on something simple — Forge driving a single VPS, a managed PHP host, or one hand-built EC2 instance. Teams move to a real AWS architecture when that single box becomes the bottleneck, the single point of failure, or the thing that cannot pass a security review.
The first trigger is scale and availability. A single VPS runs the web server, the queue workers, the scheduler, and often MySQL and Redis all on one host — fine until one traffic spike, one runaway Horizon job, or one failed disk takes everything down. On AWS, web traffic runs as stateless containers behind an Application Load Balancer across two or more Availability Zones, the database is a managed RDS/Aurora instance with automated backups and Multi-AZ failover, and the queue workers scale independently of the web tier. Nothing in Laravel changes conceptually — you are separating processes that were sharing one box.
The second trigger is compliance. A SOC 2 or ISO audit wants encrypted-at-rest databases, secrets that are not sitting in a plaintext `.env` on a shared host, network isolation, audit logging, and least-privilege access — all of which AWS provides as primitives (RDS encryption, Secrets Manager, VPC, CloudTrail, IAM) and a hand-managed VPS makes you build or fake. The honest counterpoint: Forge-on-a-VPS is simpler to operate, and AWS asks you to own more — a landing zone, IAM, networking, container images, and the PHP-FPM + nginx packaging Forge did invisibly. That operational surface is precisely why most teams do not run this migration alone — the CloudRoute angle is that a vetted AWS partner runs it for you, often funded by AWS.
Your Laravel code barely moves. MySQL stays MySQL (RDS/Aurora), Redis stays Redis (ElastiCache), S3 was already an S3 target if you used the s3 driver, and Eloquent, your migrations, your queued jobs, and Horizon are unchanged. The work is packaging and wiring — Dockerizing PHP-FPM + nginx, moving .env into Secrets Manager, and splitting web / worker / scheduler into separate services — not rewriting the app.
There is no single "AWS version of Forge," but Laravel's shape maps onto AWS cleanly once you separate the three runtime roles — web, queue workers, scheduler — and pick a compute target. The data and supporting services map almost one-to-one.
For compute, there are three realistic targets — Amazon ECS on Fargate (the workhorse: serverless containers, VPC control, independent web + worker services, an ALB in front, and no host to manage), AWS App Runner (the closest thing to Forge: point it at an image and it serves HTTPS and autoscales), and EC2 (full host control or a lift-and-shift). The callout below picks between them. The web tier is the one Laravel-specific wrinkle: a request is handled by PHP-FPM with nginx in front, so on AWS this becomes a two-container ECS task (nginx + PHP-FPM sharing the task, nginx proxying to 127.0.0.1:9000) — the pattern most partners standardize on — with the ALB terminating TLS via a free ACM certificate.
For data, MySQL → Amazon RDS for MySQL (like-for-like managed MySQL) or Aurora MySQL (AWS's MySQL-compatible engine — a little more per hour, but faster failover, autoscaling storage, up to 15 read replicas, and Serverless v2 capacity scaling). Because both speak MySQL, Eloquent, your migrations, and your queries are unchanged — you swap DB_HOST and migrate the data. Amazon ElastiCache (Redis OSS or Valkey) takes over everything Redis did on the VPS at once — cache (CACHE_STORE=redis), sessions (SESSION_DRIVER=redis), and the queue backend (QUEUE_CONNECTION=redis) Horizon consumes — one managed cluster, three jobs, a connection-string change. File storage stays on Amazon S3 via Laravel's Flysystem s3 driver; if you wrote uploads to local disk, that becomes the single most important code change, because the container filesystem is ephemeral.
For the two background roles: Horizon runs as its own ECS service (command php artisan horizon), scaling on queue depth rather than web traffic. The Laravel scheduler (the single schedule:run crontab line) becomes an Amazon EventBridge Scheduler rule firing every minute to run a one-off ECS task that calls schedule:run — covered in detail next. Outbound email keeps working through Laravel's mail config; move to Amazon SES or keep the vendor billed directly.
App Runner for the simplest Forge-like web tier when you want git-push/image-push simplicity (least new surface; you still run Horizon + scheduler elsewhere). ECS on Fargate when you want coordinated web + Horizon-worker + scheduler services, VPC networking control, and fine-grained autoscaling — the most common landing spot for "we outgrew Forge/the VPS." EC2 when you want full host control or are lift-and-shifting an existing server layout before modernizing.
The most hands-on task in a Laravel→AWS move is turning a Forge/VPS deployment into a container image and moving the flat `.env` into managed config — mechanical, but where PHP teams without container experience slow down.
On a VPS, Forge installs PHP and the right extensions, runs composer install, builds assets, and points nginx at public/index.php. On AWS you reproduce that in a Dockerfile: a build stage runs composer install --no-dev --optimize-autoloader and your asset build (npm ci && npm run build), then a runtime stage on a slim php-fpm base image installs the extensions your app uses — typically pdo_mysql, bcmath, gd or imagick, redis (via PECL), intl, zip, and opcache — copies the app, and runs the production cache commands (config:cache, route:cache, view:cache, event:cache). The nginx container is a standard image whose server block proxies PHP requests to PHP-FPM over FastCGI on port 9000.
The same image serves all three roles — you change the container command, not the image: the web service runs PHP-FPM (fronted by nginx), the Horizon service runs php artisan horizon, and the scheduler task runs php artisan schedule:run. Database migrations become a one-off task at deploy time (migrate --force as an ECS run-task or pipeline step) — never on container boot, so two tasks starting at once cannot race to migrate the same database. And enable OPcache for production, which means a deploy ships a fresh image rather than editing files in place.
Config is the other half. Laravel reads config from environment variables, so the goal is to supply the same variables the app expects — without shipping an `.env` file. Split it by sensitivity: real secrets (APP_KEY, DB_PASSWORD, API keys, mail credentials) into AWS Secrets Manager; non-sensitive config (APP_ENV, APP_URL, CACHE_STORE, QUEUE_CONNECTION) into SSM Parameter Store. The ECS task definition references both and injects them as environment variables before the container starts — so every env()/config() call behaves as it did on the VPS. The migration step is a one-time export, a sort into secret-vs-config, and a load, scripted so nothing leaks into an image layer.
The most common "it works locally but sessions break on AWS" bug is an APP_KEY mismatch across tasks. APP_KEY encrypts sessions, cookies, and signed URLs: generate it once, store it in Secrets Manager, and inject the same value into every task. Rotating it logs everyone out — treat it like the encryption key it is.
The piece most likely to be set up casually on a VPS — and to break quietly after a migration — is background work. Laravel's queue + Horizon and the scheduler each get a clean, isolated home on AWS.
On a VPS, Supervisor keeps php artisan horizon alive and a crontab line runs schedule:run every minute — both sharing the box with web traffic. On AWS, the Horizon workers become a dedicated ECS service running php artisan horizon, consuming the same Redis queues now backed by ElastiCache. Because it is its own service, it scales on queue depth independently of the web tier, and ECS restarts a crashed worker the way Supervisor used to — across AZs and without a host to babysit. (If you do not need Horizon's dashboard, Laravel also supports Amazon SQS as a fully-managed queue connection; most teams keep Redis + Horizon through the migration and consider SQS later for high-throughput queues.)
The scheduler is the subtle one. Laravel's design is a single per-minute invocation of schedule:run, which then decides which commands are due — so you must run it exactly once a minute, not once per web task. The AWS-native answer is an EventBridge Scheduler rule on a one-minute schedule that triggers a single one-off ECS task running schedule:run. That guarantees one invocation regardless of how many web containers are running — avoiding the trap of baking the cron into the web image and firing every scheduled job N times. Long-running scheduled work should dispatch a queued job so the invocation stays fast. Either way, the queue and scheduler are now isolated infrastructure, not processes fighting your web server for CPU.
The full row-by-row mapping — every Laravel building block, its AWS service, what changes, and the effort — lives in the comparison table below. Two principles make reading it safe.
First, anything that speaks a standard protocol moves with a config change and no rewrite: MySQL → RDS/Aurora MySQL, Redis (cache/session/queue) → ElastiCache, and S3 via the Flysystem s3 driver — the "trivial / low-effort" rows, where you change DB_HOST, REDIS_HOST, and the S3 credentials. Second, anything that was a host-level convention needs a one-time translation into an AWS-native primitive: the Forge/nginx + PHP-FPM setup → a Dockerfile + ECS task, `.env` → Secrets Manager + Parameter Store, the Supervisor worker → a Horizon ECS service, the crontab → EventBridge Scheduler, TLS/domains → Route 53 + ACM. Done once and documented, that is the "Medium" work a partner does for you.
The one row that is genuinely application work rather than config is local-disk storage → S3: if any part of the app wrote files to the local filesystem (avatars, generated PDFs, temp exports), that code must move to Storage::disk('s3') before cutover, because the container filesystem is ephemeral and shared by nothing. Usually small and well-contained — but it has to be found, not assumed, which is what the Assess phase is for.
Everything else — containers, the landing zone, ElastiCache, Horizon, the scheduler — can be built and tested without touching production. The MySQL cutover is the one moment that touches live data, and where a plan earns its keep. Done right, user-visible downtime is zero to fifteen minutes.
The core technique is to keep the new RDS/Aurora MySQL database continuously in sync with your current MySQL while you test, so that at cutover you switch to an already-current copy rather than dumping and loading a database under time pressure. AWS Database Migration Service (DMS) does exactly this: a full load into RDS/Aurora MySQL, then a switch into change data capture (CDC) mode that streams every subsequent insert/update/delete in near-real time by reading the source MySQL binary log. Because both ends are MySQL, this is a homogeneous migration — no Schema Conversion Tool needed; the schema moves as-is. (Binary logging on the source is the one prerequisite to confirm in the Assess phase.)
With CDC running and the new stack smoke-tested against the synced database, the cutover is short and scripted: maintenance mode (php artisan down) → drain the last few seconds of CDC → flip DB_HOST and switch traffic to the new service → update the Route 53 record (TTL lowered ahead) → a final migrate --force if pending → verify writes land in RDS → bring the app up. Because the data was already synced, the window is dominated by DNS propagation and verification, not data transfer — commonly 0–15 minutes of write-downtime during a low-traffic period.
The rollback plan is non-negotiable and simple: keep the old environment running until you are confident, and if something is wrong, switch DB_HOST and DNS back. The clean window is before RDS has accepted production writes the old database hasn't seen — so keep the rollback window short and watch closely. Lowering DNS TTL to 60 seconds a day ahead makes both the switch and the rollback fast.
A cold mysqldump | mysql makes your downtime window = export + transfer + import of the whole database: 20–60 minutes for 5GB, unacceptable at 50GB+. DMS with CDC moves the bulk load before the window and only drains the last few seconds during it — so downtime stays in single-digit minutes regardless of database size. The one prerequisite: binary logging enabled on the source MySQL.
Here is the end-to-end sequence a partner runs, mapped to the AWS MAP phases (Assess → Mobilize → Migrate). For a typical Laravel app this is a 3–7 week project — mostly parallelizable, none of it touching production until the final cutover.
Most Laravel→AWS migrations that go badly go badly for a small, repeatable set of PHP-specific reasons. Naming them up front is the cheapest insurance there is.
The definitive lookup: every Laravel building block, where it lands on AWS, what changes, and the effort. "Trivial / Low" rows are config swaps; "Medium" rows are the real packaging work — and the work a partner does for you.
| Laravel component | AWS service | What changes | Effort |
|---|---|---|---|
| HTTP app (PHP-FPM) | ECS/Fargate or App Runner (or EC2) + ALB | Forge/nginx setup → PHP-FPM + nginx container | Medium |
| Web server (nginx) | nginx container in the task + ALB (TLS via ACM) | Server block proxies FastCGI to PHP-FPM:9000 | Low |
| MySQL database | Amazon RDS for MySQL / Aurora MySQL | Swap DB_HOST; data cutover via DMS | Medium (cutover) |
| Cache store (Redis) | Amazon ElastiCache (Redis / Valkey) | Connection string only (CACHE_STORE=redis) | Trivial |
| Sessions | ElastiCache (Redis) — SESSION_DRIVER=redis | Move off file/local driver to Redis | Low |
| Queue backend | ElastiCache (Redis) for Horizon, or Amazon SQS | QUEUE_CONNECTION → Redis/SQS | Low |
| Queue workers (Horizon) | Dedicated ECS service (php artisan horizon) | Supervisor → ECS service autoscaling on queue depth | Medium |
| Scheduler (schedule:run) | EventBridge Scheduler → one-off ECS task | Crontab line → one invocation/min, never per web task | Low |
| File storage (Flysystem) | Amazon S3 (s3 driver) | Local-disk writes → Storage::disk('s3') | Low–Medium |
| .env configuration | Secrets Manager + SSM Parameter Store | Split secret vs config; inject to the task | Low |
| Outbound email | Amazon SES (or vendor direct) | Swap mailer config / SMTP credentials | Low |
| DB migrations | ECS run-task / CodeBuild step (migrate --force) | Run once at deploy, not on container boot | Low |
| Deploys (Forge/Envoyer) | CodePipeline + CodeBuild + ECR (or GitHub Actions) | Build image, push to ECR, update ECS service | Medium |
| Custom domain + SSL | Route 53 + ACM (free TLS) | DNS + cert on ALB / App Runner | Low |
Situation: The blocker was not cost — it was a stalled enterprise deal. The prospect's security questionnaire required encrypted-at-rest databases, secrets not stored in a plaintext .env on a shared host, network isolation, and no single point of failure. The three-person team had deep Laravel experience but had never run AWS, ECS, or a multi-AZ architecture, and could not risk a hand-rolled migration mid-sales-cycle.
What CloudRoute did: Routed within 24 hours to an AWS Advanced-tier partner with a Laravel track record, who ran the MAP Assess phase (free) and filed the work as a MAP engagement. Target: ECS on Fargate across two AZs (a PHP-FPM + nginx web service behind an ALB, a dedicated Horizon service), Aurora MySQL with Multi-AZ and encryption-at-rest, ElastiCache for cache + sessions + the Horizon queue, S3 via Flysystem (a few local-disk avatar writes moved to the s3 disk), .env split into Secrets Manager and Parameter Store, EventBridge Scheduler for schedule:run, and GitHub Actions → ECR → ECS with migrations as a deploy-time run-task. MySQL moved via AWS DMS (full load + CDC).
Outcome: Cutover ran in a Sunday-night window with ~9 minutes of write-downtime — DMS had Aurora fully in sync, so the switch was DB_HOST + DNS + verification, not data transfer. The architecture cleared every item on the security questionnaire (encryption-at-rest, Secrets Manager, multi-AZ, CloudTrail), and the enterprise deal closed six weeks later. Project ran ~5 weeks. Because the workload qualified for MAP, AWS funded the assessment and credited the migration cost — out-of-pocket migration cost was effectively $0, and CloudRoute's commission came from the partner via MAP.
project length: ~5 weeks · cutover downtime: ~9 min · security review: passed · migration cost to customer: ~$0 (MAP-funded)
CloudRoute routes you to a vetted AWS partner who plans and runs the whole Laravel→AWS migration — the PHP-FPM + nginx containers, RDS/Aurora MySQL, ElastiCache for cache/sessions/queues, Horizon workers, the scheduler, the DMS cutover, and the cost optimization. Qualifying migrations are MAP-funded, so you get a production-grade, multi-AZ AWS architecture without paying the usual migration bill.