Laravel on Serverless — Deploy to AWS Lambda with Bref

· 8 min read

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:work or 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)

Comments