Migrating from Laravel Vapor
Bref and Laravel Vapor both run Laravel on AWS Lambda, in your own AWS account, with the same building blocks: Lambda, API Gateway, CloudFront, SQS, S3, RDS. Migrating from Vapor to Bref is more a configuration change than a rewrite.
This guide uses Bref Cloud as the main path, because it replaces what the Vapor dashboard does: deployments, environment variables and secrets, databases, logs, metrics, queues and commands. Bref Cloud is optional though, every section also mentions how to proceed without it.
What changes
What you gain:
- A proven, actively maintained project: Bref was created in 2017 and is used by thousands of companies. It handles tens of billions of requests every month across all users, and it is referenced in the AWS Lambda documentation.
- No lock-in: Bref is open source and deploys standard CloudFormation stacks to your AWS account. If you stop using Bref, everything keeps running. Bref Cloud is optional and has a free plan for personal projects (pricing).
- Full access to AWS: you are not limited in the AWS services and features you can use. Functions can react to any Lambda event (S3, EventBridge, SNS…), and
serverless.ymlaccepts any CloudFormation resource next to the application. - Simpler assets: assets are served on the same domain as the application,
ASSET_URLis no longer needed.
What you lose:
- Deploy hooks: Vapor runs the
deployhooks (for example migrations) before the new version receives traffic. With Bref, you run the migrations afterbref deploy. That being said, in both cases old and new code use the same database for a short time: theweb,artisanand queue functions are updated one after the other, and requests and jobs in progress finish on the old version. This is also true with Vapor: migrations should be backward-compatible. - Rollbacks: there is no
vapor rollbackcommand. To roll back, redeploy the previous commit. - Infrastructure commands: Vapor creates caches and firewalls. Bref Cloud creates databases and networks, but not caches and firewalls yet. You need to create these resources in the AWS console or in
serverless.yml. This guide reuses the resources created by Vapor, so this only matters for new resources. - A shorter configuration file:
serverless.ymlis longer thanvapor.yml, because it describes each function explicitly.
Need help with the migration? Bref support includes direct help from Matthieu, creator of Bref and AWS Serverless Hero .
Migration strategy
The recommended strategy is to keep the long-lived infrastructure and migrate the application:
- Keep: the database (RDS or Aurora), the Redis cache (ElastiCache), the S3 storage bucket, the network (VPC and NAT gateway), the domain and its HTTPS certificate. Vapor created these resources in your AWS account and they will continue to work without Vapor.
- Recreate with Bref: the Lambda functions, the HTTP layer (API Gateway and CloudFront), the SQS queues and the scheduler. These are cheap, stateless and quick to recreate.
The migration then happens without downtime (except in one case, see Domain and DNS switch):
- Deploy the application with Bref next to the Vapor environment, connected to the same database, cache and bucket, without the scheduler.
- Test it on the URL generated by Bref.
- Move the scheduler from Vapor to Bref (see Scheduler), and switch the DNS record of your domain to the Bref deployment.
- Delete the Vapor environment (see Clean up).
Recreating the database and the cache is also possible (dump and restore), but it requires a maintenance window to avoid losing data. Reusing them is simpler.
Lower the TTL of your DNS records a day before switching, so that the switch propagates quickly.
Before you start
-
Create a Bref Cloud account and connect the AWS account where Vapor deploys your application. Bref Cloud connects via an IAM role, the same way Vapor does.
-
Install the
brefCLI and runbref login. -
Download the environment variables of the Vapor environment, you will need them later:
vapor env:pull productionThis creates a
.env.productionfile. Do not commit it. -
Open the AWS Lambda console and find the
vapor-<project>-<environment>function. Its configuration is a useful reference during the migration: memory, timeout, VPC subnets and security groups, and the environment variables injected by Vapor (database host, Redis host, bucket name, etc.).
If you do not want to use Bref Cloud, follow the Setup guide to create AWS credentials, install the serverless CLI, and replace bref deploy with serverless deploy throughout this guide.
Install Bref
Replace the Vapor packages with the Bref packages:
composer remove laravel/vapor-core laravel/vapor-cli
composer require bref/bref bref/laravel-bridge --update-with-dependencies
npm install --save-dev serverless-liftThe Lift plugin provides the constructs section of serverless.yml, used below for queues and the website.
If your project uses them, also remove laravel/vapor-ui (Bref Cloud replaces it, see Monitoring) and the laravel-vapor npm package (see Assets and File uploads).
Then create the serverless.yml file, which replaces vapor.yml:
php artisan vendor:publish --tag=serverless-configThe bref/laravel-bridge package does what laravel/vapor-core did at runtime: it moves the storage directory to /tmp, sends logs to stderr (CloudWatch), trusts the API Gateway proxy headers, and uses the cookie session driver when the file driver is configured (details). No code change is needed for that.
Vapor-specific files can be deleted once the migration is complete: vapor.yml, config/vapor.php and .vaporignore. Keep the uploadFiles method of your UserPolicy if you use file uploads, Bref uses the same gate.
From vapor.yml to serverless.yml
Here is a typical vapor.yml:
id: 12345
name: my-app
environments:
production:
runtime: 'php-8.4:al2023'
memory: 1024
cli-memory: 512
queue-memory: 1024
timeout: 28
domain: example.com
database: my-app-db
cache: my-app-cache
storage: my-app-storage
queues:
- default
scheduler: true
build:
- 'composer install --no-dev'
- 'npm ci && npm run build'
deploy:
- 'php artisan migrate --force'And the equivalent serverless.yml, based on the file published by bref/laravel-bridge:
service: my-app
bref:
team: my-team # your Bref Cloud team
provider:
name: aws
region: us-east-1 # the region of your Vapor project
environment:
APP_ENV: ${param:appEnv}
APP_KEY: ${ssm:/my-app/${sls:stage}/APP_KEY}
# ... see the "Environment variables and secrets" section
# Run in the Vapor network to reach the private database and cache
# (see the "Database" section)
vpc:
securityGroupIds:
- sg-0123456789abcdef0
subnetIds:
- subnet-0123456789abcdef0
- subnet-abcdef0123456789
functions:
web:
handler: public/index.php
runtime: php-84-fpm
memorySize: 1024 # vapor.yml `memory`
timeout: 28 # vapor.yml `timeout`
events:
- httpApi: '*'
artisan:
handler: artisan
runtime: php-84-console
memorySize: 1024 # vapor.yml `cli-memory`
timeout: 720 # vapor.yml `cli-timeout`
events:
# vapor.yml `scheduler: true`
# Add it when you switch from Vapor (see the "Scheduler" section)
- schedule:
rate: rate(1 minute)
input: '"schedule:run"'
constructs:
# vapor.yml `queues`
jobs:
type: queue
worker:
handler: Bref\LaravelBridge\Queue\QueueHandler
runtime: php-84
memorySize: 1024 # vapor.yml `queue-memory`
timeout: 60 # vapor.yml `queue-timeout`
# vapor.yml `domain` and assets (see the "Assets" and "Domain" sections)
website:
type: server-side-website
versionedAssets: true
assets:
'/build/*': public/build
'/favicon.ico': public/favicon.ico
'/robots.txt': public/robots.txt
domain: example.com
certificate: arn:aws:acm:us-east-1:123456789012:certificate/...
params:
default:
appEnv: ${sls:stage}
prod:
appEnv: production
package:
patterns: # replaces `.vaporignore`
- '!.env*'
- '!node_modules/**'
- '!public/build/**'
- 'public/build/manifest.json'
- '!resources/js/**'
- '!resources/css/**'
- '!storage/**'
- '!tests/**'
plugins:
- ./vendor/bref/bref
- serverless-liftVapor deploys one Lambda function for HTTP, one for the CLI and one for queues. Bref does the same, but each function is explicit in serverless.yml: web, artisan and the queue worker.
Vapor environments (production, staging) become Bref environments: bref deploy --env=production. Values that differ between environments go in the params section (Laravel environments).
vapor.yml reference
vapor.yml | serverless.yml |
|---|---|
runtime: php-8.4:al2023 | runtime: php-84-fpm (web), php-84-console (artisan), php-84 (queue worker). Runtimes |
runtime: php-8.4:al2023-arm | architecture: arm64 on each function. ARM runtimes |
runtime: docker | Deploy the bref/php-84 Docker image. Deploying Docker images |
memory, cli-memory, queue-memory | memorySize on the web, artisan and queue worker functions (1024MB is the recommended default). Function configuration |
timeout, cli-timeout, queue-timeout | timeout on each function (max 28 seconds for web, because of API Gateway). Function configuration |
concurrency | reservedConcurrency on the web function. Function configuration |
queue-concurrency | maxConcurrency on the queue construct. Lift queue documentation |
queues | One queue construct per queue. Laravel Queues |
scheduler: true | schedule event on the artisan function. Cron tasks |
warm | warmer schedule event, or provisioned concurrency. Cold starts |
database, cache, network, subnets, security-groups | Environment variables and provider.vpc. Database |
storage | AWS_BUCKET and IAM permissions. File storage |
domain, asset-domain | domain and certificate on the server-side-website construct. Domain |
octane: true | handler: Bref\LaravelBridge\Http\OctaneHandler with runtime: php-84. Laravel Octane |
octane-database-session-persist | OCTANE_PERSIST_DATABASE_SESSIONS environment variable. Persistent database connections |
gateway-version: 2 | httpApi event (the default). gateway-version: 1 is the http event. HTTP applications |
balancer | alb event. Bref does not create the load balancer, create it in the AWS console or in the resources section. Application Load Balancer |
firewall | AWS WAF. Bref does not create it, attach a WAF web ACL to the CloudFront distribution or the API Gateway in the AWS WAF console |
tmp-storage | ephemeralStorageSize on the function. Ephemeral storage |
build | Steps of your CI pipeline before bref deploy. Deploying from GitHub Actions |
deploy | bref command "migrate --force" after bref deploy. Database migrations |
ignore and .vaporignore | package.patterns. Exclusions |
separate-vendor | Not needed. Use Docker images if the application exceeds the 250MB limit |
dockerfile, docker-build-args | provider.ecr.images. Deploying Docker images |
Bref supports Laravel Octane, Docker and ARM, like Vapor. Note that extra PHP extensions are not available for ARM.
Environment variables and secrets
Vapor stores environment variables and secrets in its dashboard and injects them at deployment. With Bref, they live in serverless.yml and in your AWS account:
-
Plain values go in
provider.environmentinserverless.yml, with parameters for values that change between environments. -
Secrets (
APP_KEY, database password, API tokens) are created in Bref Cloud, in the “Secrets” tab of the environment or withbref secret:create. Bref Cloud stores them as SSM parameters in your AWS account, under/<app>/<environment>/<name>, and you reference them inserverless.yml. They are resolved at deployment time:serverless.ymlprovider: environment: APP_KEY: ${ssm:/my-app/${sls:stage}/APP_KEY} DB_PASSWORD: ${ssm:/my-app/${sls:stage}/DB_PASSWORD}
Go through the .env.production file pulled from Vapor and sort the variables into these two groups. Keep the same APP_KEY: it encrypts sessions, cookies and any encrypted data in your database.
Drop the variables that Vapor injected or that only make sense for Vapor: VAPOR_*, ASSET_URL, MIX_URL, SQS_PREFIX, DYNAMODB_CACHE_TABLE (unless sessions are stored in DynamoDB, see Cache and sessions). Replace SQS_QUEUE with the URL of the new queue (see Queues).
Without Bref Cloud: create the SSM parameters with the AWS CLI or the AWS console, the ${ssm:...} syntax works the same. Read more about secrets.
A few details:
- Lambda limits environment variables to 4KB in total. If large secrets (long tokens, private keys) exceed that limit, load them at runtime instead of at deployment time with the
bref/secrets-loaderpackage and thebref-ssm:prefix. - If you use an encrypted environment file (
.env.production.encryptedwithLARAVEL_ENV_ENCRYPTION_KEY), the simplest is to move its values toserverless.ymland Bref Cloud secrets. Alternatively, decrypt it in your CI pipeline withphp artisan env:decryptand deploy the resulting.envfile with the application. - Vapor sets
MYSQL_ATTR_SSL_CA=/var/task/rds-combined-ca-bundle.pemfor RDS MySQL databases, using a certificate bundle shipped by Vapor. If your database requires SSL connections, download the RDS certificate bundle into your project (for examplecerts/global-bundle.pem) and setMYSQL_ATTR_SSL_CA=/var/task/certs/global-bundle.pem.
Database
Keep the database created by Vapor. It is a regular RDS or Aurora database in your AWS account.
In Bref Cloud, open your AWS account page and click “Scan for apps and databases”: existing databases are imported and listed in the Databases page with their hostname and port, next to the databases created by Bref Cloud.
Then configure Laravel with the same variables Vapor injected:
provider:
environment:
DB_CONNECTION: mysql
DB_HOST: my-app-db.abc123.us-east-1.rds.amazonaws.com
DB_PORT: 3306
DB_DATABASE: vapor
DB_USERNAME: vapor
DB_PASSWORD: ${ssm:/my-app/${sls:stage}/DB_PASSWORD}The values are in the .env.production file, in the Vapor dashboard (in the database details), or in the environment variables of the Vapor Lambda function.
Private databases: Vapor places serverless databases, and optionally fixed-size databases, in a private network. The Lambda functions must then run in that VPC. Copy the subnet IDs and the security group of the Vapor Lambda function (in the Lambda console, “Configuration” then “VPC”) into serverless.yml:
provider:
vpc:
securityGroupIds:
- sg-0123456789abcdef0
subnetIds:
- subnet-0123456789abcdef0
- subnet-abcdef0123456789This reuses the Vapor network, including its NAT gateway, so the functions keep their internet access. Read more about VPC databases.
Without Bref Cloud: the configuration is identical, only the database will not appear in a dashboard.
Once deployed, run the migrations as you did with the Vapor deploy hook:
bref command "migrate --force"Vapor calls Schema::defaultStringLength(191) when the database is Aurora MySQL 5.7 (VAPOR_SERVERLESS_DB=true). If your application relies on it, add that line to the boot() method of AppServiceProvider.
Cache and sessions
Redis: if the Vapor environment has a Redis cache (ElastiCache), keep it. It lives in the Vapor network, so the provider.vpc configuration above is required. Vapor configured the Redis connection automatically (VAPOR_CACHE=true), you now have to do it in config/database.php. Vapor caches are Redis clusters, so declare it as a cluster:
'redis' => [
'client' => 'phpredis',
'options' => [
'cluster' => 'redis',
],
'clusters' => [
'default' => [
[
'host' => env('REDIS_HOST'),
'port' => 6379,
'database' => 0,
],
],
'cache' => [
[
'host' => env('REDIS_HOST'),
'port' => 6379,
'database' => 0,
],
],
],
],Set REDIS_HOST to the value injected by Vapor (in the Lambda console). The redis PHP extension is enabled by default in Bref.
DynamoDB: when no Redis cache is attached, Vapor uses a DynamoDB table as the default cache store. Cache data is disposable, so the simplest is to deploy a new table with serverless.yml. Follow the Laravel caching guide. The database cache driver is also a good option: it’s simple, fast, and avoids opening another HTTPS connection to DynamoDB.
Sessions: to keep users logged in through the migration, keep the same SESSION_DRIVER and the same session store (database, Redis or cookie). With the same APP_KEY, existing sessions remain valid.
If sessions are stored in DynamoDB (SESSION_DRIVER=dynamodb), they live in the Vapor DynamoDB table. A new table logs out all users. To avoid it, keep the Vapor table for sessions: keep the DYNAMODB_CACHE_TABLE variable and allow the functions to access the table:
provider:
environment:
SESSION_DRIVER: dynamodb
DYNAMODB_CACHE_TABLE: vapor_cache # the value from .env.production
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:BatchGetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
Resource: arn:aws:dynamodb:${aws:region}:${aws:accountId}:table/vapor_cacheYou can move sessions to another store later, when logging out users is acceptable.
Failed jobs: Vapor tracks job attempts in the cache. Bref uses Laravel’s native failed_jobs table, make sure it exists (it is part of the default Laravel migrations).
Queues
Vapor creates SQS queues for each environment and runs jobs in a queue Lambda function. Bref does the same with the Lift queue construct and the Bref\LaravelBridge\Queue\QueueHandler worker. Follow the Laravel Queues guide:
provider:
environment:
QUEUE_CONNECTION: sqs
SQS_QUEUE: ${construct:jobs.queueUrl}
constructs:
jobs:
type: queue
worker:
handler: Bref\LaravelBridge\Queue\QueueHandler
runtime: php-84
memorySize: 1024 # vapor.yml `queue-memory`
timeout: 60 # vapor.yml `queue-timeout`
maxConcurrency: 50 # vapor.yml `queue-concurrency`Declare one construct per queue listed in vapor.yml, and fifo: true for FIFO queues (Lift queue documentation ).
All Laravel Queues features work as usual: retries, failed_jobs, queue:retry, etc. The vapor:work command and the Vapor worker are no longer involved. Laravel Horizon is not applicable on Lambda, same as on Vapor.
During the switch: jobs already in the Vapor queues are processed by the Vapor environment as long as it exists. Wait for the Vapor queues to be empty (in the SQS console) before deleting the Vapor environment.
Scheduler
scheduler: true in vapor.yml runs php artisan schedule:run every minute. In serverless.yml, this is a schedule event on the artisan function:
functions:
artisan:
handler: artisan
runtime: php-84-console
timeout: 720
events:
- schedule:
rate: rate(1 minute)
input: '"schedule:run"'Like on Vapor, the scheduler is invoked once per minute, from a single place, so onOneServer() is not needed. A task that runs longer than a minute can still overlap with the next run: use withoutOverlapping() to prevent it. Sub-minute tasks work as long as the function timeout is longer than 60 seconds: schedule:run keeps running until the end of the minute.
While the Vapor environment exists, its scheduler runs every minute. If the Bref scheduler also runs, every scheduled task runs twice (emails, billing, reports…).
Deploy the Bref application without the schedule event first. When you switch the domain to Bref:
- Set
scheduler: falseinvapor.ymland runvapor deploy production. Do this before you switch the DNS record, see Domain and DNS switch. - Add the
scheduleevent to theartisanfunction and runbref deploy.
Scheduled tasks do not run between the two deployments. Do it at a time when no important task is scheduled.
File storage
Keep the S3 bucket configured with storage in vapor.yml. It is a regular S3 bucket in your AWS account: the files, URLs and lifecycle rules remain unchanged.
Configure Laravel to use it and grant the Lambda functions access to it:
provider:
environment:
FILESYSTEM_DISK: s3
AWS_BUCKET: my-app-storage
iam:
role:
statements:
- Effect: Allow
Action: s3:*
Resource:
- arn:aws:s3:::my-app-storage
- arn:aws:s3:::my-app-storage/*This replaces the storage construct of the File storage guide, which creates a new bucket. AWS credentials are provided by Lambda, there is no AWS_ACCESS_KEY_ID to set.
File uploads
Vapor provides the Vapor.store() JavaScript helper to upload files from the browser to S3, because Lambda limits request payloads to about 4.5MB. It calls the /vapor/signed-storage-url route (provided by laravel/vapor-core and protected by the uploadFiles gate), uploads the file to a tmp/ key in the bucket, and your code then moves the file with Storage::copy().
Bref has the same limit and the same solution, built into bref/laravel-bridge (Large files):
-
Keep the
uploadFilesgate (orUserPolicy::uploadFiles()): the Bref route/signed-upload-urluses the same ability. -
Replace
Vapor.store()with thestore()function of thebref-upload.jshelper shipped by the package (import it fromvendor/with a Vite alias or publish it withphp artisan vendor:publish --tag=bref-upload, see Large files):import { store } from 'bref-upload'; // Same signature and same `progress` option as Vapor.store() const { uuid, key, bucket, extension } = await store(file, { progress: (ratio) => console.log(`${Math.round(ratio * 100)}%`), });The response contains the same
uuid,key,bucket,urlandheadersfields as Vapor’s, plusextension. Keys aretmp/{user id}/{uuid}.{extension}instead oftmp/{uuid}. ThehttpClientoption is supported too, to keep using your configured axios instance;bucket,expires,visibilityanddataare not. -
Validate the key sent by the browser with the
UploadedToS3rule (it checks that the file was uploaded by the current user, exists, and matches the expected extension and size), then copy it to its final location withStorage::copy(), exactly like with Vapor.
Two things to check on the existing bucket:
- CORS: Vapor configured CORS for your domain. To test uploads on the temporary URL generated by Bref, add that origin to the CORS configuration of the bucket in the S3 console.
- Lifecycle: Vapor configured the
tmp/prefix to expire after 24 hours, this rule stays in place.
Assets
Vapor uploads the public/ directory to a separate S3 bucket, serves it through CloudFront and injects ASSET_URL. Bref uses the server-side-website construct instead: assets are uploaded to S3 and served by CloudFront under the same domain as the application. This removes the need for ASSET_URL:
- Remove
ASSET_URLandMIX_URLfrom the environment variables. - Remove
Vapor.asset()andVapor.withBaseAssetUrl()calls from your JavaScript, and theASSET_URLorVITE_VAPOR_ASSET_URLconfiguration fromvite.config.js. Theasset()helper in Blade keeps working, with URLs on the application domain. - The
serve_assetsandredirect_robots_txtoptions ofconfig/vapor.phpare replaced by theassetslist of the construct: list the files and directories that CloudFront should serve from S3 (public/build,favicon.ico,robots.txt, etc.). Anything not listed is handled by Laravel. - Build assets (
npm run build) before runningbref deploy, as you did in the Vaporbuildhook.
Domain and DNS switch
Vapor manages domains with Route 53 hosted zones and ACM certificates created in your AWS account. Both can be reused.
-
Deploy with Bref without the
domainoption first, and test the application on the URL displayed bybref deploy. -
Find the HTTPS certificate in the ACM console : CloudFront requires a certificate in the
us-east-1region. Vapor certificates for API Gateway v1 are usually inus-east-1, otherwise request a new one, which is free. -
Check if the Vapor domain is edge-optimized: in the API Gateway console , open “Custom domain names” and look at the endpoint type of
example.com. An edge-optimized domain uses a CloudFront distribution managed by AWS, and CloudFront allows a domain on only one distribution. The Bref deployment then fails with aCNAMEAlreadyExistserror. In that case, the switch causes a short downtime (a few minutes), do it at a quiet time:- Delete the
example.comcustom domain in the API Gateway console. The Vapor application is no longer reachable onexample.com. - Continue immediately with the next steps.
A regional custom domain does not have this problem, the switch has no downtime.
- Delete the
-
Add the domain and the certificate ARN to the
websiteconstruct and deploy again:serverless.ymlconstructs: website: type: server-side-website # ... domain: example.com certificate: arn:aws:acm:us-east-1:123456789012:certificate/... -
Point the DNS record of
example.comto the CloudFront domain of thewebsiteconstruct (displayed in the outputs of the deployment, or in the CloudFront console). If Vapor manages the zone, edit the record in the Route 53 console as an alias to the CloudFront distribution, and do not deploy the Vapor environment again. If you manage DNS elsewhere, update the CNAME.
The redirect_to_root option of config/vapor.php becomes redirectToMainDomain: true on the construct, with both domains listed (Lift documentation ).
Monitoring, logs and commands
| Vapor | Bref Cloud |
|---|---|
vapor deploy production | bref deploy --env=production |
vapor env:pull, vapor env:push, vapor secret | serverless.yml and bref secret:create (Environment variables) |
vapor command production "migrate" | bref command "migrate" --env=production (Console runtime) |
vapor tinker production | bref tinker --env=production |
vapor down and vapor up | MAINTENANCE_MODE environment variable (Maintenance mode) |
vapor rollback | Deploy the previous commit. Failed deployments are rolled back automatically by CloudFormation |
| Vapor dashboard and Vapor UI (logs, metrics, failed jobs) | Bref Cloud dashboard: logs, metrics, traces, queues and failed jobs (Monitoring) |
vapor database:shell, vapor jump, vapor cache:tunnel | 7777 , an SSH tunnel to private databases made by Bref maintainers |
vapor env:delete | bref remove --env=production |
Without Bref Cloud: serverless deploy, serverless bref:cli --args="migrate", CloudWatch logs and the Bref Dashboard (Monitoring).
Clean up
Once the domain points to Bref and the Vapor queues are empty, you are free to delete the Vapor environment. Make sure not to delete resources that are still in use (like the database, buckets, etc.).