Laravel on Serverless — Deploy to AWS Lambda with Bref
You already know how to deploy Laravel to EC2 (traditional) and manage infrastructure with Terraform. But there's a completely different approach: not managing any servers at all.
AWS Lambda lets you run code without provisioning or managing servers. You only pay for actual execution time — no traffic, no cost.
Bref (French for "brief") is an open-source framework that makes running PHP on AWS Lambda easy. It provides PHP runtime layers for Lambda and integrates natively with Laravel.
What is Serverless? (Simple Explanation)
Traditional (EC2):
┌──────────────────────────────┐
│ You manage: │
│ ✗ OS updates │
│ ✗ Nginx config │
│ ✗ PHP-FPM tuning │
│ ✗ SSL certificates │
│ ✗ Auto-scaling │
│ ✗ Server monitoring │
│ ✗ Security patches │
│ → Pay 24/7 │
└──────────────────────────────┘
Serverless (Lambda):
┌──────────────────────────────┐
│ AWS manages all of the above│
│ You only need to: │
│ ✓ Deploy code │
│ ✓ Configure env vars │
│ → Pay per request │
└──────────────────────────────┘
When Should You Use Serverless?
| Good fit | Not a good fit |
|---|---|
| Blog, landing page | WebSocket real-time |
| API with irregular traffic | Long-running processes (> 15 min) |
| Cron jobs, webhook handlers | Apps needing local file storage |
| MVP / Side projects | Apps needing ultra-low latency (< 50ms) |
| Traffic bursts (viral content) |
Prerequisites
# 1. Node.js (for Serverless Framework)
node --version # >= 18
# 2. Serverless Framework
npm install -g serverless
# 3. AWS credentials configured
aws sts get-caller-identity
Installing Bref for Laravel
# In your existing Laravel project
composer require bref/bref bref/laravel-bridge
# Generate serverless.yml config file
php artisan vendor:publish --tag=serverless-config
Configuring serverless.yml
This is the most important file — it declares your entire serverless architecture:
# serverless.yml
service: laravel-blog
provider:
name: aws
region: ap-southeast-1
runtime: provided.al2023
# Environment variables for Lambda
environment:
APP_NAME: LaravelBlog
APP_ENV: production
APP_DEBUG: false
APP_URL: https://your-domain.com
# Database
DB_CONNECTION: mysql
DB_HOST: ${ssm:/laravel/db-host}
DB_DATABASE: ${ssm:/laravel/db-name}
DB_USERNAME: ${ssm:/laravel/db-username}
DB_PASSWORD: ${ssm:/laravel/db-password}
# Cache & Session using DynamoDB (no Redis server needed)
CACHE_STORE: dynamodb
SESSION_DRIVER: dynamodb
DYNAMODB_CACHE_TABLE: !Ref CacheTable
# Queue using SQS
QUEUE_CONNECTION: sqs
SQS_QUEUE: !Ref JobsQueue
# Storage using S3
FILESYSTEM_DISK: s3
AWS_BUCKET: !Ref StorageBucket
# Bref
VIEW_COMPILED_PATH: /tmp/views
LOG_CHANNEL: stderr
# IAM permissions for the Lambda function
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:GetItem
- dynamodb:DeleteItem
- dynamodb:Scan
Resource:
- !GetAtt CacheTable.Arn
- Effect: Allow
Action:
- sqs:SendMessage
- sqs:ReceiveMessage
- sqs:DeleteMessage
Resource:
- !GetAtt JobsQueue.Arn
- Effect: Allow
Action:
- s3:PutObject
- s3:GetObject
- s3:DeleteObject
Resource:
- !Sub '${StorageBucket.Arn}/*'
package:
patterns:
# Exclude unnecessary files
- '!node_modules/**'
- '!tests/**'
- '!storage/**'
- '!resources/js/**'
- '!resources/css/**'
- '!.env'
- '!docker/**'
functions:
# Main function handling HTTP requests
web:
handler: public/index.php
runtime: php-84-fpm
timeout: 28
memorySize: 1024
layers:
- ${bref-extra:gd-php-84}
events:
- httpApi: '*'
# Keep Lambda warm to avoid cold starts
# warmup:
# enabled: true
# concurrency: 5
# Function for Artisan commands
artisan:
handler: artisan
runtime: php-84-console
timeout: 720
memorySize: 512
# Function for processing Queue jobs
worker:
handler: Bref\LaravelBridge\Queue\QueueHandler
runtime: php-84
timeout: 60
memorySize: 512
events:
- sqs:
arn: !GetAtt JobsQueue.Arn
batchSize: 1
# Scheduler (runs every minute)
scheduler:
handler: artisan
runtime: php-84-console
timeout: 120
memorySize: 256
events:
- schedule:
rate: rate(1 minute)
input: '"schedule:run"'
# Additional AWS resources
resources:
Resources:
# DynamoDB for cache & sessions
CacheTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:service}-cache-${sls:stage}
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: key
AttributeType: S
KeySchema:
- AttributeName: key
KeyType: HASH
TimeToLiveSpecification:
AttributeName: expires_at
Enabled: true
# SQS Queue for Laravel jobs
JobsQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: ${self:service}-jobs-${sls:stage}
VisibilityTimeout: 120
RedrivePolicy:
maxReceiveCount: 3
deadLetterTargetArn: !GetAtt DeadLetterQueue.Arn
# Dead Letter Queue
DeadLetterQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: ${self:service}-dlq-${sls:stage}
MessageRetentionPeriod: 1209600 # 14 days
# S3 bucket for file uploads
StorageBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: ${self:service}-storage-${sls:stage}
Explanation of each section:
provider.environment — Environment Variables
Lambda has no .env file. Everything is declared here or pulled from AWS SSM Parameter Store (syntax ${ssm:/path}). SSM is the secure way to store secrets — you never hardcode passwords in config files.
functions.web — HTTP Handler
runtime: php-84-fpm: Bref provides a PHP-FPM layer optimized for Lambda. It works just like traditional PHP-FPM but runs on Lambda.timeout: 28: API Gateway has a maximum timeout of 29 seconds. Set to 28 so Lambda can return an error before Gateway times out.memorySize: 1024: Lambda scales CPU proportionally to memory. 1024MB ≈ 1 vCPU. Sufficient for most Laravel requests.
functions.worker — Queue Worker
- Whenever there's a message in SQS, Lambda automatically invokes this function. No need to run
php artisan queue:workor supervisor. batchSize: 1: Process 1 job at a time. Increase if jobs are lightweight.
resources — Infrastructure
- DynamoDB replaces Redis for cache/session — no server to manage, pay-per-request.
- SQS replaces Redis Queue — durable, automatically scalable.
- Dead Letter Queue: Jobs that fail 3 times are moved here for later debugging.
Adapting Laravel for Serverless
1. Storage — Lambda is Ephemeral
Lambda functions have no persistent filesystem. You only have /tmp (512MB–10GB) and it's wiped when the container stops.
// config/filesystems.php
'disks' => [
// Use S3 instead of local
'default' => env('FILESYSTEM_DISK', 's3'),
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
],
],
2. Compiled Views — Use /tmp
// config/view.php
'compiled' => env('VIEW_COMPILED_PATH', storage_path('framework/views')),
In serverless.yml, we set VIEW_COMPILED_PATH: /tmp/views. Blade views will be compiled to /tmp instead of storage/framework/views.
3. Logging — Use stderr
// config/logging.php
'channels' => [
'stderr' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
'level' => env('LOG_LEVEL', 'info'),
],
],
Logs on stderr will automatically appear in CloudWatch Logs. No additional configuration needed.
4. Assets — CDN for Frontend
Lambda only serves PHP. Static files (CSS, JS, images) must be hosted on S3 + CloudFront:
# Upload assets to S3
aws s3 sync public/ s3://my-assets-bucket/ \
--exclude "index.php" \
--exclude ".htaccess" \
--cache-control "max-age=31536000"
// config/app.php or .env
ASSET_URL=https://d1234xyz.cloudfront.net
Deploying
# Deploy to AWS
serverless deploy
# Run migrations
serverless bref:cli -- migrate --force
# View logs
serverless logs -f web --tail
# View info
serverless info
Output after deployment:
Service Information
service: laravel-blog
stage: production
region: ap-southeast-1
endpoints:
ANY - https://abc123.execute-api.ap-southeast-1.amazonaws.com
functions:
web: laravel-blog-production-web
artisan: laravel-blog-production-artisan
worker: laravel-blog-production-worker
scheduler: laravel-blog-production-scheduler
Handling Cold Starts
A cold start is the time Lambda takes to initialize a container for the first time (or after being idle). With PHP/Laravel, this is typically 800ms–2s.
Ways to reduce cold starts:
1. Keep the package size small:
# Check package size
serverless package
ls -lh .serverless/*.zip
# Target: < 50MB
2. Optimize the autoloader:
composer install --no-dev --optimize-autoloader --classmap-authoritative
3. Cache config & routes:
php artisan config:cache
php artisan route:cache
php artisan view:cache
Add to the deploy script:
# serverless.yml
custom:
scripts:
hooks:
'before:deploy:deploy':
- php artisan config:cache
- php artisan route:cache
- php artisan view:cache
4. Provisioned Concurrency (costs extra):
functions:
web:
# ...
provisionedConcurrency: 5 # Keep 5 instances always warm
Cost: ~$15/month for 5 provisioned instances. Compare: EC2 t3.small ≈ $15/month but only 1 instance.
Cost Comparison
Example: Blog with 100,000 pageviews/month
| Item | EC2 | Serverless |
|---|---|---|
| Compute | $15/month (t3.small) | ~$0.50 (Lambda) |
| Database | $15 (RDS t3.micro) | $15 (RDS t3.micro) |
| Cache | $13 (ElastiCache) | ~$1 (DynamoDB) |
| CDN | $5 (CloudFront) | $5 (CloudFront) |
| Total | ~$48/month | ~$21.50/month |
Note: Serverless costs scale linearly with traffic. If you have 10 million requests/month, EC2 could be cheaper.
CI/CD for Serverless
# .github/workflows/deploy-serverless.yml
name: Deploy Serverless
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install Composer deps
run: composer install --no-dev --optimize-autoloader
- name: Cache Laravel
run: |
php artisan config:cache
php artisan route:cache
php artisan view:cache
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Serverless
run: npm install -g serverless
- name: Build assets
run: |
npm ci
npm run build
- name: Upload assets to S3
run: |
aws s3 sync public/build/ s3://${{ secrets.ASSETS_BUCKET }}/build/ \
--cache-control "max-age=31536000"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ap-southeast-1
- name: Deploy
run: serverless deploy --stage production
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Run migrations
run: serverless bref:cli --stage production -- migrate --force
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Known Limitations
| Limitation | Value | Impact |
|---|---|---|
| Max timeout | 15 min (API: 29 sec) | Can't use for large export/import |
| Package size | 250MB (unzipped) | Need to optimize dependencies |
/tmp storage |
512MB – 10GB | Large file uploads must stream directly to S3 |
| Concurrent executions | 1000 (default) | Request increase from AWS if needed |
| Payload size | 6MB (sync), 256KB (async) | Large file uploads use pre-signed URLs |
Conclusion
Serverless isn't a silver bullet, but it's an excellent choice for:
- Personal blog/portfolio
- APIs with irregular traffic
- Side projects where you don't want to worry about servers
With Bref, Laravel runs on Lambda with almost no code changes. You spend your time writing features instead of SSHing into servers to fix Nginx configs.
Simple rules:
- Low/medium traffic + don't want to manage servers → Serverless
- High steady traffic + need WebSocket/long-running processes → EC2/ECS
- Want both → Hybrid (Lambda for API, EC2 for WebSocket)