Running PHP on AWS Lambda with Bref and Clean Architecture
Running PHP on AWS Lambda with Bref and Clean Architecture
Introduction
AWS Lambda supports custom runtimes, which means you can run virtually any language — including PHP. In this article, we walk through a POC that deploys a PHP 8.1 application to AWS Lambda using Bref, a layer-based PHP runtime for Lambda. The application follows Clean Architecture principles and manages a simple Book resource across three Lambda functions, all wired together with Symfony Dependency Injection and deployed via the Serverless Framework.
Why PHP on Lambda?
PHP is not natively supported by AWS Lambda. However, Lambda allows you to bring your own runtime via the provided.al2 execution environment. Bref packages a PHP binary as a Lambda Layer, so your function code runs as if PHP were a first-class citizen — no Docker containers, no EC2 instances.
Architecture Overview
Caller (CLI / Event)
│
▼
┌───────────────────────────────────────────────────────────┐
│ AWS Lambda │
│ │
│ ┌─────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ addBook │ │ getBook │ │ deleteBook │ │
│ │ handler │ │ handler │ │ handler │ │
│ └──────┬──────┘ └─────┬──────┘ └───────┬──────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Symfony DI Container │ │
│ │ AddBook / GetBook / RemoveBook use cases │ │
│ └─────────────────┬───────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ BookRepository │ (in-memory / pluggable) │
│ └─────────────────┘ │
│ │
│ Runtime: PHP 8.1 via Bref Layer (provided.al2) │
└───────────────────────────────────────────────────────────┘
Project Structure
demo/
├── serverless.yml (infrastructure as code)
├── composer.json
└── src/
├── BusinessRules/ (domain — no framework dependencies)
│ ├── Entities/
│ │ └── Book.php
│ ├── Gateways/
│ │ └── BookGateway.php (interface)
│ └── UseCases/
│ ├── AddBook/
│ │ ├── AddBook.php
│ │ ├── Request/AddBookRequest.php
│ │ └── Response/AddBookResponse.php
│ ├── GetBook/
│ │ ├── GetBook.php
│ │ ├── Request/GetBookRequest.php
│ │ └── Response/GetBookResponse.php
│ └── RemoveBook/
│ ├── RemoveBook.php
│ ├── Request/RemoveBookRequest.php
│ └── Response/RemoveBookResponse.php
├── Gateways/
│ └── InMemoryBookGateway.php (implements BookGateway)
├── Events/ (Lambda entry points)
│ ├── create/index.php
│ ├── read/index.php
│ └── delete/index.php
└── config/
└── services.yml (Symfony DI configuration)
The structure is deliberately divided into two worlds: domain (BusinessRules/) that knows nothing about Lambda, and infrastructure (Events/, Gateways/) that adapts the domain to the outside world.
Infrastructure as Code: serverless.yml
All three Lambda functions are declared in a single file:
service: app
provider:
name: aws
region: eu-west-1
runtime: provided.al2
plugins:
- ./vendor/bref/bref
custom:
bref:
separateVendor: true
functions:
addBook:
handler: src/Events/create/index.php
layers:
- ${bref:layer.php-81}
getBook:
handler: src/Events/read/index.php
layers:
- ${bref:layer.php-81}
deleteBook:
handler: src/Events/delete/index.php
layers:
- ${bref:layer.php-81}
separateVendor: true tells Bref to upload the vendor/ directory as a separate Lambda Layer, keeping each function’s deployment package small and fast to update.
Clean Architecture in a Lambda Context
The project applies Clean Architecture (also known as Hexagonal Architecture): business rules are isolated at the center, and everything else adapts to them.
The Entity
// src/BusinessRules/Entities/Book.php
class Book
{
public function __construct(
public readonly string $id,
public readonly string $title,
public readonly string $author,
) {}
}
The Gateway Interface
// src/BusinessRules/Gateways/BookGateway.php
interface BookGateway
{
public function add(Book $book): Book;
public function findById(string $id): ?Book;
public function remove(string $id): void;
}
BookGateway is a pure PHP interface. The business rules layer never depends on DynamoDB, MySQL, or any other storage detail.
A Use Case
// src/BusinessRules/UseCases/AddBook/AddBook.php
class AddBook
{
public function __construct(private readonly BookGateway $bookGateway) {}
public function __invoke(AddBookRequest $request): AddBookResponse
{
$book = new Book(uniqid(), $request->title, $request->author);
$this->bookGateway->add($book);
return AddBookResponse::create($book);
}
}
Use cases are plain PHP classes. They receive a typed Request DTO, perform domain logic, and return a typed Response DTO — no HTTP, no Lambda, no framework.
The Gateway Implementation (Adapter)
// src/Gateways/InMemoryBookGateway.php
class InMemoryBookGateway implements BookGateway
{
private array $books = [];
public function add(Book $book): Book
{
$this->books[$book->id] = $book;
return $book;
}
public function findById(string $id): ?Book
{
return $this->books[$id] ?? null;
}
public function remove(string $id): void
{
unset($this->books[$id]);
}
}
Today this is in-memory storage — enough for a demo. Swapping it for a DynamoDB repository only requires implementing BookGateway with a different class and updating the DI binding in services.yml. The use cases don’t change.
Lambda Handlers
Each handler is the Lambda entry point. Its only jobs are: bootstrap the DI container, parse the event, run the use case, and return a response.
// src/Events/create/index.php
require '/tmp/vendor/autoload.php';
$container = new ContainerBuilder();
(new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config/')))->load('services.yml');
$container->compile();
return function (array $event) use ($container): array {
$request = AddBookRequest::create($event['title'], $event['author']);
/** @var AddBook $useCase */
$useCase = $container->get(AddBook::class);
$response = $useCase($request);
return ['id' => $response->book->id];
};
Note the /tmp/vendor/autoload.php path: Lambda’s ephemeral filesystem mounts the separate vendor layer under /tmp, which is why Bref’s separateVendor option writes the autoloader there.
Dependency Injection with Symfony
All services are declared in config/services.yml:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../../src/*'
exclude: '../../src/Events'
App\BusinessRules\Gateways\BookGateway:
class: App\Gateways\InMemoryBookGateway
App\BusinessRules\UseCases\AddBook\AddBook:
public: true
App\BusinessRules\UseCases\GetBook\GetBook:
public: true
App\BusinessRules\UseCases\RemoveBook\RemoveBook:
public: true
autowire: true means Symfony resolves constructor dependencies automatically. The only explicit binding is BookGateway → InMemoryBookGateway, which is exactly the Dependency Inversion Principle in action.
Invoking the Functions
# Deploy
composer install
serverless deploy
# Add a book
serverless invoke --function addBook -d '{"title": "Clean Code", "author": "Robert C. Martin"}'
# → {"id": "64a1bc..."}
# Get a book
serverless invoke --function getBook -d '{"id": "64a1bc..."}'
# → {"id": "64a1bc...", "title": "Clean Code", "author": "Robert C. Martin"}
# Delete a book
serverless invoke --function deleteBook -d '{"id": "64a1bc..."}'
# → null
Key Patterns Illustrated
| Pattern | Implementation |
|---|---|
| Custom Lambda runtime | Bref php-81 layer on provided.al2 |
| Clean Architecture | Domain isolated from Lambda, Symfony, and storage |
| Dependency Inversion | BookGateway interface decouples use cases from repository |
| Request/Response DTOs | Typed input/output for every use case |
| Dependency Injection | Symfony DI container bootstrapped per invocation |
| Separate vendor layer | Faster deploys with separateVendor: true |
| Strict types | declare(strict_types=1) enforced across all PHP files |
Going Further
This POC is intentionally minimal. To move toward production, you would:
- Add a real database — Implement a
DynamoDbBookGatewaythat implementsBookGatewayand interacts with DynamoDB viaasync-aws/dynamodb. No use case code changes required. - Add an HTTP trigger — Attach an API Gateway event to each function and map HTTP methods to the right handlers.
- Add IAM permissions — Use
serverless-iam-roles-per-functionto grant each function only the DynamoDB actions it needs. - Add tests — Use cases are plain PHP classes, easy to unit test without mocking Lambda or AWS.
- Set up CI/CD — A GitHub Actions workflow with OIDC federation can run
serverless deployon every push tomain.
Conclusion
This demo challenges the assumption that PHP and serverless don’t mix. With Bref, running PHP 8.1 on Lambda is straightforward. The key takeaways:
- Bref makes PHP a first-class Lambda runtime with zero Docker overhead
- Clean Architecture pays off in a Lambda context: business logic is testable, portable, and completely decoupled from the event source
- Symfony DI works perfectly in Lambda — lightweight enough to bootstrap per invocation without performance issues
- Serverless Framework turns multi-function infrastructure into a few lines of YAML
- Swapping the repository (in-memory → DynamoDB → any database) requires no changes to domain code — only a new implementation of
BookGateway