Why Lettr runs on AWS Lambda with Bref
Why Lettr, a Laravel application, runs on AWS Lambda through Bref: the PHP runtimes and single-file deployment Bref provides, how Lettr's serverless.yml splits HTTP, queues, S3 events, and the scheduler into separate functions with no AWS keys anywhere, the Lambda limits we hit, and why Matthieu Napoli deserves the credit.

Lettr, our email API, is a Laravel application that has never had a server. Every HTTP request, queue job, cron run, and webhook is an AWS Lambda invocation, and what makes a Laravel app run on Lambda at all is Bref, an open-source project by Matthieu Napoli. We first chose Bref for our email editor, Topol, in August 2022, two and a half years before Laravel Cloud existed. When we started building Lettr in December 2025, Bref was the obvious choice. This article covers what Bref does for us, where Lambda's limits are, and why Matthieu deserves the credit.
A 40-line file from August 2022
This is the first serverless.yml we ever committed, on 29 August 2022, in the Topol
repository:
service: topol-app
provider:
name: aws
region: eu-west-1
runtime: provided.al2
functions:
app:
handler: public/index.php
timeout: 28 # API Gateway has a timeout of 29 seconds
memorySize: 2048
layers:
- ${bref:layer.php-80-fpm}
events:
- httpApi: '*'
package:
exclude:
- .ebextensions/**
- node_modules/**
- storage/**
- tests/**
plugins:
- ./vendor/bref/bref
- ./vendor/bref/extra-php-extensionsThe .ebextensions/** exclusion in this file is a leftover from what Bref replaced.
Topol had run on AWS Elastic Beanstalk since 2017: EC2 instances, an Apache
config in the repository, deploy hooks to keep storage writable, and instances that needed
patching. Bref 1.7 on PHP 8.0 replaced all of this with one function and one file. We spent the
following month moving secrets to Parameter Store, adding a GD layer, and trying PHP 8.1. We
deleted the Elastic Beanstalk folder in November 2022, and the Topol app has not had a server to
patch since.
As of September 2026, this file is 251 lines across 126 commits. It runs PHP 8.4, SQS-driven
queue workers, and a long list of scheduled commands. Bref has gone from 1.7 to 2 underneath.
Lettr started on Bref 2 and moved to Bref 3 in March 2026, and that upgrade was a 28-line diff to serverless.yml: layers: [${bref:layer.php-84-fpm}] became runtime: php-84-fpm. Three years and three major versions later, the whole
deployment is still a single file the team can read.
What Bref does
Lambda does not run PHP. Bref's core contribution is a set of open-source PHP runtimes
for Lambda, built and published for every supported PHP version in every AWS region and
kept current with PHP releases. Lettr runs on PHP 8.5. The runtime was published before we
needed it, and the upgrade was runtime: php-85-fpm. Building and maintaining these
runtimes is the expensive part of running PHP on Lambda, and it is the part no team wants to
take on themselves.
On top of the runtimes, Bref adds:
- One file for the whole deployment. A single
serverless.ymlcovers functions, events, memory, timeouts, IAM, and environment variables.serverless deployturns it into CloudFormation, so anything AWS can do, the file can do too. - Per-function scaling. Each entry under
functions:scales independently. Our API and our web UI are the samepublic/index.php, deployed twice with different memory, because they have different traffic. - Pay per invocation. There is no idle capacity, so a queue that receives no messages overnight costs nothing overnight.
- A Laravel bridge that handles what Laravel assumes about a filesystem: caches
written under
/tmp, compiled views, logs sent tostderrso they land in CloudWatch, sessions and cache stored in DynamoDB, and a ready-made SQS handler for the queue worker. - Lift, the companion plugin that builds the S3 bucket and CloudFront distribution for static assets from a few lines of config.
None of this is proprietary. The runtimes are public, the code is on GitHub, and the output is standard CloudFormation.
How Lettr uses Bref
Lettr's serverless.yml follows the same pattern as Topol's, with more functions.
Here is a trimmed version, with names generalised:
provider:
iam:
role:
statements: # the execution role is the only AWS credential the app has
- Effect: Allow
Action: [dynamodb:GetItem, dynamodb:PutItem, dynamodb:Query]
Resource: arn:aws:dynamodb:<region>:<account>:table/<table>
- Effect: Allow
Action: [sqs:SendMessage, sqs:DeleteMessage]
Resource: arn:aws:sqs:<region>:<account>:<queue>
# ... one statement per table, bucket and queue the app touches
functions:
web:
handler: public/index.php
runtime: php-85-fpm
events:
- httpApi: '*'
api: # same code, separate Lambda, scales on its own
handler: public/index.php
runtime: php-85-fpm
events:
- httpApi: { path: '/api/{proxy+}', method: '*' }
<name>-queue:
handler: Bref\LaravelBridge\Queue\QueueHandler
runtime: php-85
events:
- sqs: { arn: arn:aws:sqs:<region>:<account>:<queue>, batchSize: 1 }
# ... one function per queue, each with its own timeout and memory
webhook-processor:
handler: handlers/webhooks.php
runtime: php-85
events:
- s3: { bucket: <webhooks-bucket>, event: 's3:ObjectCreated:*' }
artisan:
handler: artisan
runtime: php-85-console
events:
- schedule: { rate: rate(1 minute), input: '"schedule:run"' }HTTP. Three FPM functions run the same Laravel codebase: web for
the Inertia app, api for the REST API, and mcp for the MCP server, which gets its own Lambda
so the Passport keys it needs never reach the other two.
Queues. Each SQS queue is a separate Lambda with its own timeout and memory. Campaign sends, audience exports, and the AI assistant's long-running template generation all have their own, so a slow job never delays a fast one. SQS invokes the Bref queue handler directly, so there is no worker process to supervise and no Horizon dashboard to watch. Every job is one Lambda invocation with its own log stream, and a campaign to a large audience runs across many workers at once.
Events. Delivery webhooks land in S3 as batches, and S3 fires a Lambda for each object. The handler is forty lines:
return new class extends S3Handler
{
public function handleS3(S3Event $event, Context $context): void
{
foreach ($event->getRecords() as $record) {
app(ProcessWebhookBatch::class)(
$record->getBucket()->getName(),
urldecode($record->getObject()->getKey()),
);
}
}
};Scheduler. One console function runs schedule:run every minute,
and Laravel's scheduler decides what is due. Topol's serverless.yml listed each cron
entry separately; Lettr's needs one line.
Everything else. Sessions and cache live in DynamoDB. Secrets are ${ssm:/...} references resolved at deploy time, so no secret is ever
in the repository or the CI log. Lift builds the CloudFront distribution in front of assets.lettr.com.
No AWS keys anywhere. Every DynamoDB table, S3 bucket, and SQS queue the
application touches is granted through the function's own IAM role, declared in the iam block of serverless.yml, so the AWS SDK authenticates with the
Lambda execution role. The same applies to deployment: a GitHub Actions job runs serverless deploy on every push to main, after the test suite, by
assuming a role through OIDC. No long-lived AWS credential exists in the repository, in CI
secrets, or in the Lambda environment.
What Bref does not do
Bref's documentation states Lambda's limits up front, and we hit the real ones.
28 seconds per HTTP request. API Gateway cuts off at 29 seconds. Our AI assistant's chat turn originally ran inside the request and regularly outlived it, so it now runs on a long-timeout queue and the browser receives the result over a broadcast.
4 KB of environment variables per function. Lambda merges provider-level and function-level environment variables and caps the total. Adding one token pushed a queue function 69 bytes over the limit. The fix was deleting variables that matched their config defaults, which we should have done anyway.
No GD in the base runtime. Our brand-kit pipeline measures logo luminance and
generates a light/dark twin. Without the GD image library it skipped both, and a white logo
published white on white. bref/extra-php-extensions provides the layer; the
diagnosis took longer than the fix.
15 minutes per invocation, and no long-lived connections. Lettr's SMTP endpoint holds TCP connections open, which Lambda cannot do. This service is a small Go program on ECS, and it is the only piece of Lettr that is not a Lambda function.
Thank you, Matthieu Napoli
Bref is written and maintained by Matthieu Napoli, who funds the work through GitHub
Sponsors. He also maintains oss-serverless, the open-source
continuation of the Serverless Framework CLI after version 4 moved to a commercial licence,
which is why our deploy step still runs serverless deploy without a subscription.
Other contributors like Marco Deleu and Thomas Richard have also carried significant parts of
the work.
We have built two commercial products on Bref, one of them since 2022. Every PHP release we wanted has had a runtime ready, and every major upgrade has been boring. This is what good open-source maintenance looks like from the outside. For a company that runs PHP on Lambda, sponsoring Matthieu is a small line next to the AWS bill.
FAQ
Why Bref and not Laravel Vapor or Laravel Cloud?
Does cold start matter for an email API?
What does it cost?
Can I run my Laravel app on Lambda with Bref?
/tmp, and Bref's documentation lists them up front.Bottom line
Bref turned running Laravel on Lambda from a project into a deployment target. We made this bet for Topol in 2022 with a 40-line file, and it has held through three PHP versions, three Bref major versions, and a second product. Lettr's infrastructure is one YAML file, a queue handler, and an S3 event, and none of it is a server anyone has to maintain.
For a Laravel application that sends email, create a free Lettr account and start with the Laravel quickstart.