Why Bref Instead of Vapor

Laravel Vapor is the official serverless deployment tool for Laravel. It works. I've used it on two client projects. But it costs $399 per year just for the subscription, and on top of that you're still paying for all the AWS resources underneath. What bothered me most wasn't the price though. It was the abstraction. Vapor manages your infrastructure behind the scenes, and when something goes sideways at 1am, you're digging through Vapor's CloudFormation stacks trying to figure out what it actually provisioned. I wanted more control.

So I started looking for alternatives. Bref has been around for a while now. It's an open-source project that gives you PHP runtimes for AWS Lambda. You get the same serverless model as Vapor, but you own every single piece of infrastructure. And because it's just Lambda layers, you can wire everything up with whatever IaC tool you prefer.

This post walks through deploying a full Laravel application to Lambda using Bref and Terraform. I'm talking the whole setup. Aurora Serverless v2 for Postgres, ElastiCache Serverless for Redis, SQS for queues, EventBridge for cron, S3 for uploads. If you've read my previous posts, some of this architecture will look familiar. In Serverless SaaS on AWS Lambda with Fastify I covered many of the same Lambda patterns but with Node.js. And in Deploying a Laravel Application to Amazon EC2 Using Terraform I showed the traditional EC2 approach. Think of this post as the upgrade from that EC2 setup. Same Terraform mindset, but no servers.

We're deploying Laravel 13 by the way. Everything here works with Laravel 11 and 12 too, but the config examples match 13.

Let's get into it.

Laravel developer at a modern workstation
Photo by ThisIsEngineering on Pexels

Architecture Overview

Here's the full picture of what we're building:

Serverless Laravel Architecture: API Gateway, Lambda Bref FPM, Aurora Serverless v2, ElastiCache, S3, SQS, EventBridge

Three Lambda functions total. The web Lambda handles HTTP via the Bref FPM runtime (php-83-fpm), which boots PHP-FPM and proxies API Gateway events to it. Your Laravel app thinks it's running behind Nginx. The worker Lambda processes SQS jobs, and the artisan Lambda fires schedule:run every minute via EventBridge. Both use the Bref CLI runtime (php-83), no FPM overhead.

Aurora Serverless v2 scales Postgres capacity in ACUs based on actual load. No instance sizing, no patching, Multi-AZ by default. If you'd rather skip VPC networking entirely, Supabase and Neon both work as drop-in Postgres replacements accessible over the public internet. For this post we're staying full AWS.

ElastiCache Serverless gives you pay-per-use Redis with zero cluster management. Laravel needs it for sessions and cache because Lambda's filesystem is ephemeral between cold starts. Both Aurora and ElastiCache live inside a VPC, so your Lambda functions need to be there too.

Cloud infrastructure architecture diagram
Photo by Brett Sayles on Pexels

Setting Up Laravel + Bref

First, pull in the Bref packages:

composer require bref/bref bref/laravel-bridge php artisan vendor:publish --tag=serverless-config 

The bref/laravel-bridge package does the heavy lifting: it bootstraps Laravel for Lambda, handles FPM event conversion, and provides Artisan handlers for queue and CLI invocations. After publishing you'll get a serverless.php entrypoint in your project root.

Lambda doesn't have a .env file on disk. You pass environment variables through the Lambda function config. Here's what needs to change from a typical server setup:

SESSION_DRIVER=cookie CACHE_STORE=redis FILESYSTEM_DISK=s3 LOG_CHANNEL=stderr QUEUE_CONNECTION=sqs VIEW_COMPILED_PATH=/tmp/views 

Cookie sessions are stateless and the simplest starting point. Upgrade to Redis sessions if your session data outgrows the 4KB cookie limit. Redis cache is mandatory because Lambda's filesystem doesn't persist across cold starts. Stderr logging goes straight to CloudWatch. And /tmp/views is the only writable path for compiled Blade templates. Any temp files your app writes (uploads, exports) should also target /tmp before being pushed to S3.

Aurora Serverless v2 + ElastiCache Serverless

Let's talk about the database and cache layers before we get into Terraform, because understanding these services makes the infrastructure code much easier to follow.

Aurora Serverless v2 scales Postgres capacity in ACUs instead of fixing an instance size. You set a minimum (I use 0.5) and a maximum (4 to 8 for most Laravel apps), and Aurora adjusts within that range based on load. Billing is per ACU-hour, roughly $0.12 in us-east-1. It's real Aurora under the hood: same replication, same query optimizer, Multi-AZ by default.

From Laravel's side, the connection config is standard Postgres:

DB_CONNECTION=pgsql DB_HOST=hrr-laravel-db.cluster-xxxxxxxxxxxx.us-east-1.rds.amazonaws.com DB_PORT=5432 DB_DATABASE=hrr_laravel DB_USERNAME=hrr_admin DB_PASSWORD=<from-secrets-manager> 

If you'd rather avoid VPC networking for the database, Supabase and Neon are both solid alternatives. They're publicly accessible Postgres, so you just swap the host and skip the NAT Gateway entirely. For this guide we're going full AWS.

ElastiCache Serverless is pay-per-use Redis with no node type or replication group to manage. Bref's PHP layer includes phpredis, so no extra setup needed.

REDIS_HOST=hrr-laravel-cache-xxxxxx.serverless.use1.cache.amazonaws.com REDIS_PORT=6379 

Both services require VPC placement, so your Lambda functions need to be in the same VPC. If your app calls external APIs (Stripe, Mailgun, etc.), you'll need a NAT Gateway in a public subnet. That runs about $32 per month and is often the biggest fixed cost in what's supposed to be a "serverless" setup.

Terraform Infrastructure

Time for the fun part. The full Terraform config is around 300 lines, so I'll walk through the key blocks and explain what connects to what.

VPC and Subnets

We need a VPC with both public and private subnets. Lambda, Aurora, and ElastiCache go in private subnets. The NAT Gateway sits in a public subnet so Lambda can reach the internet.

resource "aws_vpc" "hrr_laravel_vpc" { cidr_block = "10.0.0.0/16" enable_dns_support = true enable_dns_hostnames = true tags = { Name = "hrr-laravel-vpc" } } resource "aws_subnet" "hrr_private_a" { vpc_id = aws_vpc.hrr_laravel_vpc.id cidr_block = "10.0.1.0/24" availability_zone = "${var.aws_region}a" tags = { Name = "hrr-laravel-private-a" } } resource "aws_subnet" "hrr_private_b" { vpc_id = aws_vpc.hrr_laravel_vpc.id cidr_block = "10.0.2.0/24" availability_zone = "${var.aws_region}b" tags = { Name = "hrr-laravel-private-b" } } resource "aws_subnet" "hrr_public" { vpc_id = aws_vpc.hrr_laravel_vpc.id cidr_block = "10.0.3.0/24" availability_zone = "${var.aws_region}a" map_public_ip_on_launch = true tags = { Name = "hrr-laravel-public" } } 

Two private subnets in different AZs because Aurora requires at least two. One public subnet for the NAT Gateway and Internet Gateway.

Aurora Serverless v2

This part tripped me up the first time. Aurora Serverless v2 doesn't use the old engine_mode = "serverless". That was v1. For v2, you set engine_mode = "provisioned" and add a serverlessv2_scaling_configuration block. Confusing naming, I know.

resource "aws_rds_cluster" "hrr_laravel_db" { cluster_identifier = "hrr-laravel-db" engine = "aurora-postgresql" engine_mode = "provisioned" engine_version = "16.4" database_name = "hrr_laravel" master_username = "hrr_admin" master_password = var.db_password db_subnet_group_name = aws_db_subnet_group.hrr_laravel.name vpc_security_group_ids = [aws_security_group.hrr_db_sg.id] skip_final_snapshot = true serverlessv2_scaling_configuration { min_capacity = 0.5 max_capacity = 4.0 } tags = { Name = "hrr-laravel-db" } } resource "aws_rds_cluster_instance" "hrr_laravel_db_instance" { cluster_identifier = aws_rds_cluster.hrr_laravel_db.id instance_class = "db.serverless" engine = aws_rds_cluster.hrr_laravel_db.engine engine_version = aws_rds_cluster.hrr_laravel_db.engine_version tags = { Name = "hrr-laravel-db-instance" } } resource "aws_db_subnet_group" "hrr_laravel" { name = "hrr-laravel-db-subnet" subnet_ids = [ aws_subnet.hrr_private_a.id, aws_subnet.hrr_private_b.id, ] } 

The key is instance_class = "db.serverless". That tells Aurora to use serverless v2 scaling on this instance. The cluster-level serverlessv2_scaling_configuration sets the ACU range.

ElastiCache Serverless

resource "aws_elasticache_serverless_cache" "hrr_laravel_cache" { engine = "redis" name = "hrr-laravel-cache" cache_usage_limits { data_storage { maximum = 1 unit = "GB" } ecpu_per_second { maximum = 1000 } } subnet_ids = [ aws_subnet.hrr_private_a.id, aws_subnet.hrr_private_b.id, ] security_group_ids = [aws_security_group.hrr_cache_sg.id] } 

ElastiCache Serverless is beautifully simple compared to the old way. No replication groups, no node types, no parameter groups. You set usage limits and AWS handles everything else.

Lambda Function (Bref FPM)

resource "aws_lambda_function" "hrr_laravel_web" { function_name = "hrr_laravel_web" runtime = "provided.al2023" architectures = ["arm64"] handler = "Bref\\LaravelBridge\\Http\\OctaneHandler" memory_size = 1024 timeout = 28 filename = "${path.module}/dist/app.zip" source_code_hash = filebase64sha256("${path.module}/dist/app.zip") layers = [ "arn:aws:lambda:${var.aws_region}:534081306603:layer:php-83-fpm:68", ] vpc_config { subnet_ids = [ aws_subnet.hrr_private_a.id, aws_subnet.hrr_private_b.id, ] security_group_ids = [aws_security_group.hrr_lambda_sg.id] } environment { variables = { APP_ENV = "production" APP_KEY = var.app_key DB_CONNECTION = "pgsql" DB_HOST = aws_rds_cluster.hrr_laravel_db.endpoint DB_PORT = "5432" DB_DATABASE = "hrr_laravel" DB_USERNAME = "hrr_admin" DB_PASSWORD = var.db_password REDIS_HOST = aws_elasticache_serverless_cache.hrr_laravel_cache.endpoint[0].address REDIS_PORT = "6379" CACHE_STORE = "redis" SESSION_DRIVER = "cookie" LOG_CHANNEL = "stderr" QUEUE_CONNECTION = "sqs" SQS_QUEUE = aws_sqs_queue.hrr_laravel_queue.url FILESYSTEM_DISK = "s3" AWS_BUCKET = aws_s3_bucket.hrr_laravel_uploads.id } } tags = { Name = "hrr-laravel-web" } } 

A few things to note. The runtime is provided.al2023, not php83 or anything like that. Bref provides the PHP runtime as a Lambda layer. The handler points to Bref's Laravel bridge, which bootstraps your application and converts API Gateway events into HTTP requests that FPM can process.

I set memory_size to 1024MB. Lambda allocates CPU proportionally to memory, so 1024MB gives you a decent amount of compute. The timeout is 28 seconds because API Gateway HTTP API has a 29-second hard limit. You want Lambda to finish before the gateway times out, otherwise you get a 502 with no useful error message. I learned this one by debugging a 502 for about 45 minutes before realizing the Lambda was timing out at 30 seconds, one second after API Gateway gave up.

Using arm64 (Graviton) gives you about 20% better price-performance than x86. Bref supports both architectures.

API Gateway HTTP API

resource "aws_apigatewayv2_api" "hrr_laravel_api" { name = "hrr-laravel-api" protocol_type = "HTTP" } resource "aws_apigatewayv2_integration" "hrr_laravel_integration" { api_id = aws_apigatewayv2_api.hrr_laravel_api.id integration_type = "AWS_PROXY" integration_uri = aws_lambda_function.hrr_laravel_web.invoke_arn payload_format_version = "2.0" } resource "aws_apigatewayv2_route" "hrr_laravel_default" { api_id = aws_apigatewayv2_api.hrr_laravel_api.id route_key = "\$default" target = "integrations/${aws_apigatewayv2_integration.hrr_laravel_integration.id}" } resource "aws_apigatewayv2_stage" "hrr_laravel_stage" { api_id = aws_apigatewayv2_api.hrr_laravel_api.id name = "\$default" auto_deploy = true } resource "aws_lambda_permission" "hrr_laravel_apigw" { statement_id = "AllowAPIGateway" action = "lambda:InvokeFunction" function_name = aws_lambda_function.hrr_laravel_web.function_name principal = "apigateway.amazonaws.com" source_arn = "${aws_apigatewayv2_api.hrr_laravel_api.execution_arn}/*/*" } 

The $default route catches everything and sends it to Lambda. Laravel's router handles the actual URL matching, so we don't need to define API Gateway routes for every endpoint. The payload_format_version = "2.0" is important because Bref expects the v2 event format.

S3 Bucket and IAM

resource "aws_s3_bucket" "hrr_laravel_uploads" { bucket = "hrr-laravel-uploads-${var.aws_account_id}" tags = { Name = "hrr-laravel-uploads" } } resource "aws_iam_role" "hrr_laravel_lambda_role" { name = "hrr-laravel-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } }] }) } resource "aws_iam_role_policy_attachment" "hrr_lambda_vpc" { role = aws_iam_role.hrr_laravel_lambda_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } resource "aws_iam_policy" "hrr_laravel_app_policy" { name = "hrr-laravel-app-policy" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"] Resource = "${aws_s3_bucket.hrr_laravel_uploads.arn}/*" }, { Effect = "Allow" Action = ["sqs:SendMessage", "sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"] Resource = aws_sqs_queue.hrr_laravel_queue.arn }, { Effect = "Allow" Action = ["secretsmanager:GetSecretValue"] Resource = "*" } ] }) } resource "aws_iam_role_policy_attachment" "hrr_laravel_app" { role = aws_iam_role.hrr_laravel_lambda_role.name policy_arn = aws_iam_policy.hrr_laravel_app_policy.arn } 

The IAM role gives Lambda permission to run inside the VPC, access S3 for uploads, send and receive SQS messages, and pull secrets from Secrets Manager. In production, you'd scope the Secrets Manager resource down to specific secret ARNs instead of using a wildcard.

Infrastructure as code on a terminal screen
Photo by Godfrey Atima on Pexels

SQS Queues and EventBridge Cron

Laravel's queue system on Lambda works differently than on a traditional server. Normally you'd run php artisan queue:work as a long-running process. On Lambda, that's not possible. Instead, SQS triggers a Lambda function for each message, and Bref's queue handler processes it as a single job invocation.

SQS Queue and Dead Letter Queue

resource "aws_sqs_queue" "hrr_laravel_dlq" { name = "hrr_laravel_dlq" message_retention_seconds = 1209600 tags = { Name = "hrr-laravel-dlq" } } resource "aws_sqs_queue" "hrr_laravel_queue" { name = "hrr_laravel_queue" visibility_timeout_seconds = 120 message_retention_seconds = 1209600 redrive_policy = jsonencode({ deadLetterTargetArn = aws_sqs_queue.hrr_laravel_dlq.arn maxReceiveCount = 3 }) tags = { Name = "hrr-laravel-queue" } } 

The visibility timeout is set to 120 seconds, which is about twice the Lambda timeout. If a job fails and Lambda doesn't delete the message, SQS waits 120 seconds before making it visible again for retry. After 3 failed attempts, the message moves to the DLQ where it sits for 14 days.

Queue Worker Lambda

resource "aws_lambda_function" "hrr_laravel_worker" { function_name = "hrr_laravel_worker" runtime = "provided.al2023" architectures = ["arm64"] handler = "Bref\\LaravelBridge\\Queue\\QueueHandler" memory_size = 1024 timeout = 60 role = aws_iam_role.hrr_laravel_lambda_role.arn filename = "${path.module}/dist/app.zip" source_code_hash = filebase64sha256("${path.module}/dist/app.zip") layers = [ "arn:aws:lambda:${var.aws_region}:534081306603:layer:php-83:68", ] vpc_config { subnet_ids = [ aws_subnet.hrr_private_a.id, aws_subnet.hrr_private_b.id, ] security_group_ids = [aws_security_group.hrr_lambda_sg.id] } environment { variables = { APP_ENV = "production" APP_KEY = var.app_key DB_CONNECTION = "pgsql" DB_HOST = aws_rds_cluster.hrr_laravel_db.endpoint DB_PORT = "5432" DB_DATABASE = "hrr_laravel" DB_USERNAME = "hrr_admin" DB_PASSWORD = var.db_password REDIS_HOST = aws_elasticache_serverless_cache.hrr_laravel_cache.endpoint[0].address REDIS_PORT = "6379" CACHE_STORE = "redis" LOG_CHANNEL = "stderr" QUEUE_CONNECTION = "sqs" SQS_QUEUE = aws_sqs_queue.hrr_laravel_queue.url } } tags = { Name = "hrr-laravel-worker" } } resource "aws_lambda_event_source_mapping" "hrr_laravel_sqs_trigger" { event_source_arn = aws_sqs_queue.hrr_laravel_queue.arn function_name = aws_lambda_function.hrr_laravel_worker.arn batch_size = 1 function_response_types = ["ReportBatchItemFailures"] } 

Notice this Lambda uses the php-83 layer, not php-83-fpm. Queue workers don't need FPM because there's no HTTP request to process. The handler Bref\LaravelBridge\Queue\QueueHandler knows how to deserialize SQS messages back into Laravel job objects. Your job classes work exactly like they would on a normal server. You dispatch jobs the same way too. dispatch(new ProcessPodcast($podcast)) sends a message to SQS, and the worker Lambda picks it up.

EventBridge Cron for Artisan Schedule

resource "aws_lambda_function" "hrr_laravel_artisan" { function_name = "hrr_laravel_artisan" runtime = "provided.al2023" architectures = ["arm64"] handler = "artisan" memory_size = 512 timeout = 120 role = aws_iam_role.hrr_laravel_lambda_role.arn filename = "${path.module}/dist/app.zip" source_code_hash = filebase64sha256("${path.module}/dist/app.zip") layers = [ "arn:aws:lambda:${var.aws_region}:534081306603:layer:php-83:68", "arn:aws:lambda:${var.aws_region}:534081306603:layer:console:68", ] vpc_config { subnet_ids = [ aws_subnet.hrr_private_a.id, aws_subnet.hrr_private_b.id, ] security_group_ids = [aws_security_group.hrr_lambda_sg.id] } environment { variables = { APP_ENV = "production" APP_KEY = var.app_key DB_CONNECTION = "pgsql" DB_HOST = aws_rds_cluster.hrr_laravel_db.endpoint DB_PORT = "5432" DB_DATABASE = "hrr_laravel" DB_USERNAME = "hrr_admin" DB_PASSWORD = var.db_password REDIS_HOST = aws_elasticache_serverless_cache.hrr_laravel_cache.endpoint[0].address REDIS_PORT = "6379" CACHE_STORE = "redis" LOG_CHANNEL = "stderr" } } tags = { Name = "hrr-laravel-artisan" } } resource "aws_cloudwatch_event_rule" "hrr_laravel_cron" { name = "hrr-laravel-cron" schedule_expression = "rate(1 minute)" tags = { Name = "hrr-laravel-cron" } } resource "aws_cloudwatch_event_target" "hrr_laravel_cron_target" { rule = aws_cloudwatch_event_rule.hrr_laravel_cron.name arn = aws_lambda_function.hrr_laravel_artisan.Source:https://dev.to/harundotdev/serverless-laravel-on-aws-lambda-with-bref-and-terraform-3i5m

Related posts