LaravelAWS LambdaBrefServerlessPHPBackendDevOps

How to Deploy a Laravel Application on AWS Lambda With Bref: Step-by-Step Tutorial

Deploy Laravel serverless on AWS Lambda with Bref. A step-by-step guide to Laravel serverless deployment: SQS, DynamoDB, S3, a custom domain, and CI/CD.

Kashyap Kumar·
How to Deploy a Laravel Application on AWS Lambda With Bref: Step-by-Step Tutorial

Running PHP on AWS Lambda gives you no servers to patch and a bill that scales to zero. It also gives you a read-only disk, no queue daemon, and a 29-second request limit. This guide shows how to deploy Laravel serverless on AWS Lambda with Bref, the open-source tool that runs PHP on Lambda. It is a complete Laravel serverless deployment: you take a fresh app to production, wiring up the web, queue, scheduler, uploads, a custom domain, and CI, and hitting the real problems, not just the happy path.

Names used throughout: app acme-app, domain example.com, region ap-south-1.

What you will build

A small Laravel app with placeholder code that exercises every moving part:

  • a web endpoint behind API Gateway,
  • a queued job draining from SQS,
  • a scheduled command running every minute,
  • a custom artisan command you run on demand,
  • a file upload that stores to S3,
  • session and cache backed by DynamoDB,
  • a health-check route.

Before you start

You need:

  • an AWS account,
  • a domain with DNS on Cloudflare (or another external DNS provider),
  • local tools: PHP 8.3, Composer, Docker (for Sail), Node.js and the AWS CLI.

How serverless changes a Laravel app

Bref packages PHP as Lambda layers (a layer is a zip of shared files Lambda mounts into your function) so a normal Laravel app boots on AWS Lambda (code that runs on demand, per request, with no server). Three rules follow, and they drive the whole guide:

  1. No server. The app wakes per request and freezes after. Nothing survives in memory between requests.
  2. Read-only disk. Only /tmp is writable, and only per instance. File sessions, the file cache, and file logs all break.
  3. No long processes. A web request must finish in 29 seconds. Long work moves to a queue.

The fix for all three is the same: move Laravel's file, session, cache, and queue drivers to managed AWS services. That is what makes a serverless Laravel app work, and it is most of the work below.

When does serverless Laravel fit? It suits spiky or low traffic, where Lambda scales to zero and you pay per request. It fits less well for steady high traffic or long-lived processes (WebSockets, Laravel Octane, Horizon), where an always-on server is simpler and cheaper, and the route for that is deploying on a VPS.

Bref or Laravel Vapor?

Both run Laravel on AWS Lambda. Laravel Vapor is a paid, managed service with a dashboard. Bref is open-source and free, and you own the AWS config directly. Pick Vapor for convenience, Bref for control and no per-project fee. This guide uses Bref.

Architecture

Keep this picture open while you follow the steps. Three Lambda functions (web, worker, console) sit behind managed services that hold the state Lambda cannot.

Architecture diagram of a Laravel application on AWS Lambda with Bref, showing Cloudflare DNS, CloudFront, API Gateway routing to the web Lambda, SQS feeding the worker Lambda, EventBridge triggering the console Lambda, with DynamoDB, S3, and CloudWatch as managed services
Architecture diagram of a Laravel application on AWS Lambda with Bref, showing Cloudflare DNS, CloudFront, API Gateway routing to the web Lambda, SQS feeding the worker Lambda, EventBridge triggering the console Lambda, with DynamoDB, S3, and CloudWatch as managed services

Step 1: Create the Laravel app

Use Sail (Laravel's Docker environment) so you do not install PHP or MySQL locally.

curl -s "https://laravel.build/acme-app?with=mysql,redis" | bash
cd acme-app
./vendor/bin/sail up -d

Step 2: Add the demo pieces

Each piece is intentionally dumb. The goal is to test the plumbing. The file path is the first line of every block.

Add a queued job that only logs:

// app/Jobs/ProcessUpload.php
class ProcessUpload implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $path) {}

    public function handle(): void
    {
        Log::info('Processing upload', ['path' => $this->path]);
    }
}

Add a custom artisan command:

// app/Console/Commands/Heartbeat.php
class Heartbeat extends Command
{
    protected $signature = 'acme:heartbeat';

    public function handle(): void
    {
        Log::info('heartbeat', ['at' => now()->toIso8601String()]);
    }
}

Schedule it every minute:

// routes/console.php
Schedule::command('acme:heartbeat')->everyMinute();

Add the routes: a health check, a cache-and-session counter, and an upload that stores to S3 and dispatches the job.

// routes/web.php
Route::get('/health', fn () => response()->json(['ok' => true, 'at' => now()]));

Route::get('/counter', function (Request $request) {
    $hits = Cache::increment('hits');
    $request->session()->put('last_seen', now()->toIso8601String());
    return response()->json(['hits' => $hits]);
});

Route::post('/uploads', function (Request $request) {
    $request->validate(['file' => 'required|file|max:10240']);
    $path = $request->file('file')->store('uploads', 's3');
    ProcessUpload::dispatch($path);
    return response()->json(['path' => $path], 201);
});

Locally this uses files, Redis, and local disk. None of those exist on Lambda, so the next steps replace them.

Step 3: Install Bref and the CLI

Install the PHP packages:

composer require bref/bref bref/laravel-bridge --update-with-dependencies

Install the deploy CLI globally (it needs Node.js):

npm i -g serverless # or: npm i -g osls

Which CLI? The Serverless Framework changed its license in v4 and now charges larger organizations. osls ("Open Serverless") is a free community fork of the last MIT version. Commands are identical; this guide uses serverless.

Step 4: Write serverless.yml

serverless.yml is the one file that describes your whole deploy. The CLI compiles it into AWS resources. Create it in the project root with three functions, one per way your app runs.

# serverless.yml
service: acme-app

provider:
  name: aws
  region: ap-south-1
  runtime: provided.al2
  memorySize: ${param:memory} # per-stage, from params below
  environment:
    APP_ENV: production
    APP_DEBUG: 'false'
    LOG_CHANNEL: stderr

# Per-stage values. Deploy with: serverless deploy --stage prod
params:
  prod:
    memory: 1024
    domain: example.com
  staging:
    memory: 512
    domain: staging.example.com

plugins:
  - ./vendor/bref/bref

functions:
  web: # HTTP, behind API Gateway
    handler: public/index.php
    runtime: php-83-fpm
    timeout: 28
    environment:
      BREF_BINARY_RESPONSES: '1'
    events:
      - httpApi: '*'

  worker: # pulls jobs off SQS
    handler: Bref\LaravelBridge\Queue\QueueHandler
    runtime: php-83
    timeout: 60
    events:
      - sqs:
          arn: !GetAtt JobsQueue.Arn
          batchSize: 1
          maximumConcurrency: 5

  console: # runs artisan, including the scheduler
    handler: artisan
    runtime: php-83-console
    timeout: 120
    events:
      - schedule:
          rate: rate(1 minute)
          input: '"schedule:run"'

This is the most important file in the guide, so here is what each part does.

provider sets defaults for every function. runtime: provided.al2 is the base Amazon Linux runtime that Bref's layers sit on. memorySize reads from params, and everything under environment becomes an env variable in all three functions.

params holds per-stage values. serverless deploy --stage prod uses the prod block, --stage staging uses staging. Keeping every stage in one file is standard for a production app, so it is here from the start, not bolted on later.

web is your HTTP app.

  • handler: public/index.php points Lambda at Laravel's front controller.
  • runtime: php-83-fpm is the PHP-FPM runtime, the same model your local Nginx talks to.
  • httpApi: '*' tells API Gateway (the HTTP front door that invokes Lambda) to forward every path and method here.
  • timeout: 28 sits just under API Gateway's hard 29-second limit.
  • BREF_BINARY_RESPONSES: '1' lets it return images and PDFs (Step 9 explains why).

worker processes queued jobs.

  • It has no HTTP event. The sqs event subscribes it to the queue, so SQS (a managed message queue) invokes it when a job arrives.
  • handler: ...QueueHandler is Bref's bridge that turns an SQS message into a normal Laravel job.
  • batchSize: 1 runs one job per invocation. maximumConcurrency: 5 caps parallel workers so they do not overwhelm your database.

console runs artisan.

  • handler: artisan targets Laravel's console kernel.
  • The schedule event is EventBridge (AWS's scheduler). Every minute it invokes the function with schedule:run, Laravel's scheduler entry point. This replaces the cron line you would add on a normal server.

The arn: !GetAtt JobsQueue.Arn reference points at a queue you create in the next step.

Step 5: Move drivers to managed services

Set drivers by environment, never in code, so the same app still runs locally. Each block below shows its file on the first line.

Sessions and cache → DynamoDB

DynamoDB is a managed NoSQL key-value store, a good fit for keyed data with an expiry.

# .env
SESSION_DRIVER=dynamodb
CACHE_STORE=dynamodb
DYNAMODB_CACHE_TABLE=acme-app-cache

Add the table to serverless.yml. It needs a key partition key and a TTL attribute expires_at, which is what Laravel's DynamoDB store reads:

# serverless.yml (add a top-level resources: block)
resources:
  Resources:
    CacheTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: acme-app-cache
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - { AttributeName: key, AttributeType: S }
        KeySchema:
          - { AttributeName: key, KeyType: HASH }
        TimeToLiveSpecification:
          AttributeName: expires_at
          Enabled: true

Queue → SQS

# .env
QUEUE_CONNECTION=sqs
SQS_PREFIX=https://sqs.ap-south-1.amazonaws.com/123456789012
SQS_QUEUE=acme-app-jobs

Add the queue and a dead-letter queue (a backup queue that catches jobs that fail repeatedly) under the same resources.Resources block, next to CacheTable:

# serverless.yml (inside resources: > Resources:)
    JobsQueue:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: acme-app-jobs
        VisibilityTimeout: 360 # must exceed the worker timeout several times over
        RedrivePolicy:
          deadLetterTargetArn: !GetAtt JobsDLQ.Arn
          maxReceiveCount: 3
    JobsDLQ:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: acme-app-jobs-dlq

Set the visibility timeout high. It is how long a job stays hidden after a worker picks it up. If it expires mid-run, SQS gives the same job to a second worker and it runs twice. Use several times the worker timeout (here 60s, so 360).

Do not use Horizon. It needs a long-running process, which Lambda has no place for. The plain SQS driver plus Bref's worker gives you retries and the dead-letter queue instead.

Files → S3

S3 is AWS's managed file storage.

# .env
FILESYSTEM_DISK=s3
AWS_BUCKET=acme-app-uploads
AWS_DEFAULT_REGION=ap-south-1

Do not set AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY. The next callout explains why.

The credential trap. Lambda gives each function a temporary role credential (with a session token) through environment variables. Laravel's config/filesystems.php, config/queue.php, and config/cache.php pass key and secret to the AWS SDK but not the token, so calls fail with a signature error. Fix it: leave key and secret empty on Lambda so the SDK reads the full role credential itself.

// config/filesystems.php, the s3 disk
'key' => env('AWS_ACCESS_KEY_ID') ?: null,
'secret' => env('AWS_SECRET_ACCESS_KEY') ?: null,

Do the same for the sqs and dynamodb blocks.

Logs → CloudWatch

You already set LOG_CHANNEL=stderr in serverless.yml. Lambda ships anything on stderr to CloudWatch (AWS's log store). No log files, no extra config.

Give the functions permission

The functions run under one execution role. Grant it access to the bucket and table you just created, by adding an iam block inside the existing provider:

# serverless.yml (merge into the existing provider: block)
provider:
  iam:
    role:
      statements:
        - Effect: Allow
          Action: [s3:PutObject, s3:GetObject, s3:DeleteObject]
          Resource: arn:aws:s3:::acme-app-uploads/*
        - Effect: Allow
          Action: [dynamodb:GetItem, dynamodb:PutItem, dynamodb:DeleteItem, dynamodb:UpdateItem]
          Resource: !GetAtt CacheTable.Arn

The complete serverless.yml

You added several fragments above. Here is the whole file assembled, so you can check placement before deploying. You add the customDomain block (Step 10) and optional layers (Step 11) later.

# serverless.yml
service: acme-app

provider:
  name: aws
  region: ap-south-1
  runtime: provided.al2
  memorySize: ${param:memory}
  environment:
    APP_ENV: production
    APP_DEBUG: 'false'
    LOG_CHANNEL: stderr
  iam:
    role:
      statements:
        - Effect: Allow
          Action: [s3:PutObject, s3:GetObject, s3:DeleteObject]
          Resource: arn:aws:s3:::acme-app-uploads/*
        - Effect: Allow
          Action: [dynamodb:GetItem, dynamodb:PutItem, dynamodb:DeleteItem, dynamodb:UpdateItem]
          Resource: !GetAtt CacheTable.Arn

params:
  prod:
    memory: 1024
    domain: example.com
  staging:
    memory: 512
    domain: staging.example.com

plugins:
  - ./vendor/bref/bref

functions:
  web:
    handler: public/index.php
    runtime: php-83-fpm
    timeout: 28
    environment:
      BREF_BINARY_RESPONSES: '1'
    events:
      - httpApi: '*'

  worker:
    handler: Bref\LaravelBridge\Queue\QueueHandler
    runtime: php-83
    timeout: 60
    events:
      - sqs:
          arn: !GetAtt JobsQueue.Arn
          batchSize: 1
          maximumConcurrency: 5

  console:
    handler: artisan
    runtime: php-83-console
    timeout: 120
    events:
      - schedule:
          rate: rate(1 minute)
          input: '"schedule:run"'

resources:
  Resources:
    CacheTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: acme-app-cache
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - { AttributeName: key, AttributeType: S }
        KeySchema:
          - { AttributeName: key, KeyType: HASH }
        TimeToLiveSpecification:
          AttributeName: expires_at
          Enabled: true
    JobsQueue:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: acme-app-jobs
        VisibilityTimeout: 360
        RedrivePolicy:
          deadLetterTargetArn: !GetAtt JobsDLQ.Arn
          maxReceiveCount: 3
    JobsDLQ:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: acme-app-jobs-dlq

Step 6: Set environment variables and secrets

On a normal server, Laravel reads config from a .env file. There is no .env on Lambda, so each function reads real environment variables that you set in serverless.yml, and env() reads them exactly as before. Non-secret values can sit inline; secrets like APP_KEY and the database password must not live in Git, so store them in SSM Parameter Store (AWS's config and secret store) and reference them.

  1. Put each secret in SSM once per stage, as a SecureString:
aws ssm put-parameter --name /acme-app/prod/APP_KEY --type SecureString --value 'base64:your-app-key'
aws ssm put-parameter --name /acme-app/prod/DB_PASSWORD --type SecureString --value 'your-db-password'

(Single-quote the value so the shell does not expand a $.)

  1. Reference them in serverless.yml, next to the non-secret values already in provider.environment. ${sls:stage} picks the parameter for the stage you deploy:
# serverless.yml (expand provider.environment)
provider:
  environment:
    APP_ENV: production
    APP_DEBUG: 'false'
    LOG_CHANNEL: stderr
    APP_KEY: ${ssm:/acme-app/${sls:stage}/APP_KEY}
    DB_CONNECTION: mysql
    DB_HOST: your-db-host.example.com
    DB_DATABASE: acme
    DB_USERNAME: acme
    DB_PASSWORD: ${ssm:/acme-app/${sls:stage}/DB_PASSWORD}

Serverless resolves ${ssm:...} at deploy time and bakes the values into the function's environment. The running function reads plain env variables, so it never needs SSM access itself; only the deploy identity does (Step 7 grants that).

Why this matters: Miss this and the app boots on Lambda with no APP_KEY or database config and returns a 500. The .env you use locally does not travel to Lambda; these variables replace it.

Simpler alternative. To keep secrets in GitHub instead, reference ${env:APP_KEY} in serverless.yml and pass the values to the deploy step in CI (Step 12) with an env: block. SSM keeps secrets inside AWS and out of your CI provider, which is why it is the default here.

Step 7: Create the AWS deploy user

serverless deploy needs AWS credentials of its own. These are separate from the function execution role above.

  1. In the AWS IAM console, create a user acme-deploy with programmatic access (or an IAM Identity Center permission set for SSO).
  2. Attach the policy below. Paste it as an inline policy on that user in the IAM console; it is not a file in your project. Start broad, then tighten to acme-app-* once your first deploy works.
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "cloudformation:*", "lambda:*", "apigateway:*", "iam:*",
      "s3:*", "sqs:*", "dynamodb:*", "events:*", "logs:*",
      "ssm:GetParameter", "ssm:GetParameters",
      "acm:DescribeCertificate", "acm:ListCertificates"
    ],
    "Resource": "*"
  }]
}
  1. Save the credentials as a local profile so the CLI can read them:
aws configure --profile acme-deploy # paste the access key and secret
export AWS_PROFILE=acme-deploy

Deploy keys never go in the app. They live only on your machine or in CI. Lambda uses its own role at runtime, so keep these out of .env. iam:* is in the policy because the deploy creates that execution role for you, and ssm:GetParameter lets it resolve the secrets from Step 6.

Step 8: Deploy and verify

serverless deploy --stage prod

The output prints the API Gateway URL, like https://abc123.execute-api.ap-south-1.amazonaws.com. Verify every part:

CheckCommandExpected
Webcurl <url>/health{"ok":true,...}
Cache + sessioncurl <url>/counter twicehits increases (DynamoDB works)
Queuecurl -F file=@photo.jpg <url>/uploadsfile in S3, "Processing upload" in CloudWatch
Schedulewait 1 min, check console logsheartbeat line appears

Keep the package under 250 MB. Lambda's unzipped limit is 250 MB, and aws/aws-sdk-php alone can use over 100 MB. A Laravel app needs only S3, SQS, and DynamoDB, so prune the unused SDK services in a Composer step to stay small and cut cold starts.

Step 9: Serve static assets from S3, not Lambda

Lambda can serve your CSS and JS, but every request wakes a function and burns runtime. For production, serve assets from S3 through CloudFront (AWS's CDN). This is one-time infrastructure; the CI pipeline in Step 12 does the actual build and upload on every deploy.

Set it up once:

  1. Create an S3 bucket for assets, for example acme-app-assets.
  2. Create a CloudFront distribution with that bucket as its origin, on a subdomain like assets.example.com.
  3. Point Laravel at the CDN:
# .env
ASSET_URL=https://assets.example.com

Now asset() and Vite URLs resolve to CloudFront, so browsers never hit Lambda for a stylesheet. On each deploy, CI uploads the built files with aws s3 sync public/build s3://acme-app-assets/build (see Step 12).

Images or PDFs return a 500? Add BREF_BINARY_RESPONSES: '1' to the web function (already in the config above). API Gateway needs binary responses base64-encoded, and Bref only does that with this flag.

On updates, never use --delete. Old browser tabs still request the previous hashed files during a rollout. Leave old assets in place and clean up later.

Step 10: Add a custom domain (Cloudflare + ACM)

You want example.com instead of the API Gateway hostname. You need admin access to the domain in Cloudflare, because you add DNS records there by hand. New to DNS records? See how DNS works.

  1. In ACM (AWS Certificate Manager, free TLS certificates), request a certificate for example.com.

Region matters. For a regional HTTP API, the certificate must be in the API's region (ap-south-1), not us-east-1. The us-east-1 rule is only for CloudFront.

  1. ACM gives you a CNAME to prove ownership. Add it in Cloudflare and wait for the certificate to validate.
  2. Add the domain plugin and a custom block to serverless.yml. The domain reads from the per-stage params you set in Step 4:
# serverless.yml (add the plugin and a top-level custom: block)
plugins:
  - ./vendor/bref/bref
  - serverless-domain-manager

custom:
  customDomain:
    domainName: ${param:domain} # example.com for prod, from params
    certificateArn: arn:aws:acm:ap-south-1:123456789012:certificate/abcd-1234
    endpointType: regional
    apiType: http
    createRoute53Record: false # you use Cloudflare, not Route 53
  1. Run serverless create_domain once. It prints a target like d-xxxx.execute-api.ap-south-1.amazonaws.com.
  2. In Cloudflare, add a CNAME from example.com to that target. If you proxy through Cloudflare, set SSL mode to Full.

Migrating an existing app? Flip DNS last. Deploy and test on the API Gateway URL first, lower your Cloudflare record's TTL a day ahead, then switch the CNAME.

Step 11: Add PHP extensions and raise upload limits (if needed)

Bref's base layer is lean. It has no GD (image library) or intl, and small upload limits. Add extra layers and a php.ini only if your app needs them.

# serverless.yml (add to the web function)
functions:
  web:
    layers:
      - ${bref-extra:gd-php-83}
      - ${bref-extra:intl-php-83}
; php/conf.d/php.ini  (its own file; Bref loads it automatically)
upload_max_filesize = 20M
post_max_size = 21M
memory_limit = 512M

Step 12: Automate deploys with CI/CD

Deploy from a Linux runner, because PHP extension binaries are platform-specific. This GitHub Actions job builds, uploads assets, deploys, and runs migrations.

# .github/workflows/deploy.yml
name: deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write # OIDC, no stored keys
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.3' }
      - run: composer install --no-dev --optimize-autoloader
      - run: npm ci && npm run build
      - run: aws s3 sync public/build s3://acme-app-assets/build # assets first, no --delete
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/acme-deploy
          aws-region: ap-south-1
      - run: npm i -g osls
      - run: serverless deploy --stage prod
      - run: serverless bref:cli --stage prod --args="migrate --force"

Because the app's secrets live in SSM (Step 6), the deploy resolves them with the OIDC role at deploy time. No application secrets are stored in GitHub; only the deploy role is trusted. The migrate --force step runs on Lambda, where the database env variables are already in place from the deploy.

Run a command or read logs on demand:

serverless bref:cli --stage prod --args="acme:heartbeat"
serverless logs -f web --stage prod --tail

Single-quote secrets in the shell. A password like p@ss$word has a $; double quotes make the shell expand $word and truncate it. Write DB_PASSWORD='${{ secrets.DB_PASSWORD }}'.

Prefer OIDC over stored keys. configure-aws-credentials with role-to-assume gets short-lived credentials, so nothing leaks from the repo.

Production checklist

  • Cold starts: a cold start is the delay when Lambda boots a fresh instance. For latency-sensitive traffic, add provisioned concurrency (pre-warmed instances).
  • Memory: more memory means more CPU. Bumping the web function to 1024 MB often lowers cost by finishing faster.
  • Database connections: Lambda scales wide and each instance opens a connection. Cap the worker (maximumConcurrency) and consider RDS Proxy.
  • Log retention: set a retention period on CloudWatch log groups; the default is forever.
  • Dead-letter alarm: alert on the dead-letter queue depth so failed jobs do not go unnoticed.
  • VPC only if the database is private. A public managed database needs no VPC (a private AWS network). A private RDS instance needs one, and then Lambda loses internet, so it needs a NAT gateway or VPC endpoints to reach S3, SQS, and DynamoDB. A VPC also adds cold-start latency and cost.
  • Managed-DB quirks: if a database rejects a connection init command that another accepts, apply it conditionally (keyed on an env flag), not globally.

On cost: Lambda, API Gateway, SQS, DynamoDB, and S3 all scale to near-zero when idle. The two things that do not are provisioned concurrency and a NAT gateway, both billed around the clock.

Bref and Laravel on Lambda: FAQ

Can you run Laravel on AWS Lambda? Yes. Laravel is not serverless by default, but Bref packages PHP as Lambda layers and runs your app almost unchanged, once you move sessions, cache, files, and the queue to managed services. This guide walks through the whole process.

Is Bref free? Yes. Bref is open-source. You pay only AWS for what you use. Laravel Vapor adds a subscription on top.

Does Laravel Horizon work on AWS Lambda? No. Horizon needs a long-running process. Use the SQS queue driver with Bref's worker; SQS gives you retries and a dead-letter queue.

How much does it cost to run Laravel on Lambda? For low or spiky traffic, very little. The AWS free tier covers about 1 million requests and 400,000 GB-seconds a month. Avoid a VPC unless a private database forces one.

Can I use MySQL or RDS? Yes. A public managed database needs no VPC. A private RDS instance needs a VPC plus a NAT gateway or VPC endpoints.

Which PHP versions does Bref support? Current versions, roughly 8.1 to 8.4. Match the runtime to your app, for example php-83-fpm.

Recap

You deployed a Laravel app to AWS Lambda with Bref, moved every stateful driver to a managed service (DynamoDB, SQS, S3, CloudWatch), replaced cron with EventBridge, added a custom HTTPS domain through Cloudflare and ACM, and automated it with CI. The recurring theme: a read-only disk, no long processes, and a 29-second limit, so state lives in managed services and long work goes to the queue.

What to try next

  • Add provisioned concurrency to the web function and measure the cold-start drop.
  • Alarm on the dead-letter queue, then force a job to fail and watch it fire.
  • Deploy a staging copy on staging.example.com with serverless deploy --stage staging.
  • Move a slow endpoint onto the queue so it never risks the 29-second limit.

To sharpen the Laravel and PHP fundamentals behind this, see the Top 111 Laravel interview questions and the PHP syntax recap.