I moved a multi-domain Laravel 12 application off Laravel Vapor onto Laravel Cloud, and the migration was less about code than about everything wrapped around the code. Vapor is serverless Lambda: you own an AWS account, a vapor.yml, an encrypted env file, and a deploy that builds and uploads an artifact. Laravel Cloud is a managed platform: you connect a repository, pick a branch, and pushes deploy themselves. The application itself needed almost no changes. What needed real work was the environment variables (there is no encrypted env file anymore), the database (MySQL on RDS became managed Postgres), object storage (S3 became Cloudflare R2, which rejects ACL headers), and the health check (the framework default returned 404 under strict domain routing). Budget your migration time for those four, plus a full test run against the new database engine. The application code was the easy part.
What is the actual difference between Vapor and Laravel Cloud?
Vapor deploys your Laravel app to AWS Lambda inside your own AWS account. You configure infrastructure in vapor.yml, you own the queues and the RDS instance, and you pay AWS directly. It is powerful and it is yours, which also means the operational surface is yours.
Laravel Cloud is managed hosting: databases, caches, storage, domains, and deploys all live behind one dashboard and one CLI. You give up direct control over the underlying AWS resources and get back the hours you used to spend on IAM policies and Lambda concurrency settings.
The practical difference on a small team of one is that Cloud removes an entire category of work. There is no artifact build to debug, no cold-start tuning, no separate queue worker sizing exercise.
What broke first: environment variables
On Vapor, production configuration lives in .env.production.encrypted, committed to the repository and decrypted with a key held by the Vapor project. That flow does not exist on Laravel Cloud. Variables live in the dashboard or are pushed through the CLI.
Two lessons came out of this:
Export your variables before you delete the Vapor project. The decryption key belongs to the Vapor project. Once the project is gone, every encrypted blob in your git history is permanently unreadable. I removed the encrypted file only after the new environment was populated and verified.
Audit the key list itself, then the values. Moving platforms is the right moment to diff the variable names across local, staging, and production. Any key that exists in one environment and not another is a config() call that silently returns null somewhere.
What changed in the database layer?
Production moved from MySQL on RDS to managed Postgres. For a Laravel application that uses Eloquent throughout, the ORM absorbs most of the difference. The places that noticed were:
- Raw SQL and
DB::raw()fragments, especially anything using MySQL-only functions or backtick quoting - Case sensitivity in string comparisons, which Postgres enforces and MySQL's default collation does not
GROUP BYstrictness, where Postgres rejects selected columns that are not grouped or aggregated- JSON column operators, which have different syntax on each engine
- Migration column types that had MySQL-specific attributes
The honest way to find these is not code review. It is running the full test suite against Postgres before the cutover, then running the real application against a restored copy of production data.
How do deploys work after the move?
Push to deploy. Merging to the production branch triggers a build, and pushing to the staging branch deploys staging. There is no local deploy command in the normal flow, which removes an old failure mode: a laptop deploying something that was never pushed.
The commands worth keeping in muscle memory:
cloud deploy:monitor -n # watch the deploy you just triggered
cloud tinker <env> --code='...' -n # run PHP against production
cloud command:run <env> --cmd='...' -n # run an artisan command
cloud environment:variables -n --force # manage env vars from the CLI
Monitoring the deploy is not optional. A green push is not a green application, and the deploy log is where a failed migration or a missing variable shows up.
What is the hibernation tradeoff?
Laravel Cloud can hibernate an environment when it is idle and wake it on the next request. For a portfolio of low-traffic marketing sites that is exactly right: you stop paying for compute that sits waiting for a visitor who arrives twice an hour.
The cost is the wake-up. The first request after hibernation is slow, and that first request might be your uptime monitor, a client checking a link, or a search crawler. If a property has a latency SLA or heavy crawler traffic, keep it awake and pay for it. If it is a brochure site, hibernate it and stop thinking about it.
Why did the health check start returning 404?
This one cost real debugging time, so it is worth spelling out.
Laravel ships a framework health route at /up. In a multi-domain app that matches routes with Route::domain(), an unmatched hostname has no route tree, and the platform health probe hits the application on a host that never matches. The result is a 404 on a route that exists, on an application that is perfectly healthy.
The fix was a dedicated global route:
Route::get('/health', HealthCheckController::class);
It lives in the global route file (outside any domain group), pings the database and the cache so it fails when a dependency fails, and returns an uncacheable response so a CDN never serves a stale "healthy" answer.
If you route strictly by domain, verify the probe path before the cutover, not after the platform starts reporting your app as down.
What about object storage?
Storage moved to Cloudflare R2 through Laravel's s3 disk driver. R2 speaks the S3 API, so the driver works unchanged, with one hard rule: R2 rejects any request carrying an x-amz-acl header, returning a 501.
That means no visibility option on uploads, no ->put($path, $contents, 'public'), and no ACL configuration in the disk definition. Public access is granted at the bucket level instead. Every upload path in the application had to be checked for an ACL argument, including the ones inside packages.
What would I do differently next time?
Three things:
- Stand up the new environment fully before touching the old one. Same variables, same database contents, same storage, verified by running the real application against it. Cheap insurance for a platform-level change.
- Test against the new database engine in CI first. Switching the CI database to Postgres weeks before the cutover would have surfaced the raw-SQL issues without any deadline pressure.
- Delete the old platform's artifacts in a single commit, after the new one is verified. Leaving
vapor.yml, deploy scripts, and old CI workflows in the repository creates references that look authoritative and are not. When something points at a decommissioned platform, fix the reference rather than restoring the file.
The migration itself was undramatic. Almost everything that consumed time lived at the edges: configuration, data, storage semantics, and the probe that tells the platform your application is alive.