bref/symfony-bridge
Bref runtime bridge for running Symfony on AWS Lambda. Install with Composer, extend BrefKernel in your Kernel, and deploy with a warm cache to reduce cold-start time. Avoid filesystem writes in production.
1. Install the bridge
composer require bref/symfony-bridge
# if you are on Bref 3, make sure Bref itself is recent enough (see gotchas)
composer require bref/bref:^3.0.13
This pulls in bref/bref and bref/monolog-bridge as dependencies. On a fresh Symfony Flex app it also registers Bref\SymfonyBridge\BrefSymfonyBridgeBundle in config/bundles.php.
2. Swap the base Kernel — the only required code change
// src/Kernel.php
namespace App;
use Bref\SymfonyBridge\BrefKernel; // + added
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
// use Symfony\Component\HttpKernel\Kernel as BaseKernel; // - removed
class Kernel extends BrefKernel // was: extends BaseKernel
{
use MicroKernelTrait;
// ...
}
That is it: BrefKernel redirects Symfony's cache/log directories to a writable location and symlinks the cache you prebuilt at deploy time, so a read-only /var/task doesn't blow up on boot.
3. Look at these three files first
| File | Why |
|---|---|
serverless.yml |
handler, layers (${bref:layer.php-83-fpm} vs -php-83), BREF_LOOP_MAX |
src/Kernel.php |
getWritableCacheDirectories() is your main tuning knob |
| your deploy script | cache:clear --env=prod must run before serverless deploy |
4. First real use case — a warm-cache HTTP app
# deploy.sh
composer install --classmap-authoritative --no-dev --no-scripts
bin/console cache:clear --env=prod # builds + warms var/cache/prod
echo "<?php return [];" > .env.local.php # disable Dotenv at runtime
serverless deploy
Without the warmup step the first request on every new Lambda pays 1–20 s of container compilation. With it, cold starts are dominated by autoload + boot, not cache building.
5. Sanity check locally — BrefKernel behaves like a normal kernel outside Lambda, so bin/console, PHPUnit and serverless invoke local all keep working unchanged.
functions:
app:
handler: public/index.php
layers:
- ${bref:layer.php-83-fpm}
Use this unless you are deliberately avoiding FPM. You keep the standard public/index.php front controller and the bridge only handles cache wiring. PHP-FPM reaps per-request temp files itself, so the upload-cleanup behaviour added in 3.0.3 is a no-op here.
functions:
app:
handler: App\Kernel # resolved via Symfony Runtime
layers:
- ${bref:layer.php-83} # note: no -fpm
environment:
BREF_LOOP_MAX: 100 # recycle the process every 100 requests
Bref invokes App\Kernel::handle() in a loop. Fewer processes, lower cold-start count, but services survive between invocations — see the state-leak gotcha below. This is also the mode where uploaded files used to pile up in /tmp; since 3.0.3 the bridge deletes the previous request's uploads at the start of the next one.
SQS/Kinesis/S3 events with Symfony Messenger:
functions:
sqsHandler:
handler: App\Service\MyConsumer # a *class* name, not a file
layers:
- ${bref:layer.php-83}
// config/services.yaml
services:
App\Service\MyConsumer:
public: true # REQUIRED — the runtime fetches it from the container
The runtime boots the kernel from public/index.php and pulls the service out of the container. This is the cleanest way to reuse your existing Messenger handlers, repositories and Doctrine connections on Lambda.
public/index.php)<?php // kernel.php
require_once __DIR__.'/vendor/autoload_runtime.php';
return function (array $context) {
return new App\Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
functions:
sqsHandler:
handler: kernel.php:App\Service\MyConsumer # file:class
Note the file:class syntax — without a colon, the whole string is treated as a class name.
/tmpIn runtime (non-FPM) mode, Symfony's UploadedFile objects land in sys_get_temp_dir() = /tmp. The bridge only sweeps the previous request's uploads when the next request begins, so a single invocation that writes hundreds of megabytes can still exhaust /tmp (512 MB default) before anything cleans it.
// config/services.yaml
parameters:
# keep request-scoped uploads off the Lambda disk entirely
app.upload_target: '%env(S3_UPLOAD_BUCKET)%'
Prefer presigned direct-to-S3 uploads for anything non-trivial and treat UploadedFile handling as suitable only for small payloads (API Gateway caps the request body at ~10 MB anyway). If you do keep local uploads, move them out of /tmp before the request ends — move_uploaded_file into a directory you control still counts against the same 512 MB.
composer install --no-dev --classmap-authoritative --no-scriptsbin/console cache:clear --env=prod (clear + warmup in one).env.local.php returning [] so Dotenv is skipped and Lambda env vars winserverless deployKeep step 2 late — anything that changes the container config after warmup invalidates it.
Add the bundle explicitly if Flex didn't:
// config/bundles.php
Bref\SymfonyBridge\BrefSymfonyBridgeBundle::class => ['all' => true],
With bref/monolog-bridge ^2.0 every log line is prefixed with the Lambda invocation ID, which makes CloudWatch Logs Insights queries per-request possible.
Override the writable directories so the pools/ directory is symlinked instead of copied:
protected function getWritableCacheDirectories(): array
{
return []; // only safe if you never touch a filesystem cache pool
}
Flex will not register the bundle on upgrade. composer update bref/symfony-bridge skips Flex recipes. Existing apps keep Monolog's default JSON logs silently. Add BrefSymfonyBridgeBundle to config/bundles.php by hand. BrefBundle still works but is deprecated.
A service handler must be public: true. Private services are removed from the container and the runtime will fail to resolve them at invocation time — with a confusing error in CloudWatch, not at deploy time.
Never write to var/cache at runtime. /var/task is read-only. Anything using cache.adapter.filesystem as a pool must move to cache.adapter.array (per-invocation) or a real backend (Redis/DynamoDB). If you truly need per-Lambda scratch space, /tmp is writable but dies with the container.
Env vars are baked into the compiled container. Symfony resolves %env()% at compile time for many settings. Change a Lambda env var after deploy and the container will not rebuild — you get stale config. Re-deploy, or keep runtime-only values out of the compile step.
State leaks in kept-alive mode. With handler: App\Kernel and BREF_LOOP_MAX, the container is not rebooted per request the way FPM is. Never store request data on a service property or a static; inject RequestStack instead. Tune BREF_LOOP_MAX down if you see memory creep.
/tmp leaks on 3.0.2 and below. Prior to 3.0.3, uploaded files were never deleted in runtime (non-FPM) mode: they accumulated across invocations until /tmp filled, at which point everything writing to /tmp failed — Monolog's stream handler included, so the failure looked like a logging problem rather than an upload problem. Upgrade to 3.0.3. The tell-tale signature is a No space left on device / Disk quota exceeded in CloudWatch after a period of working fine.
3.0.3 requires Bref 3.0.13 on the Bref 3 line. Before Bref 3.0.13, Bref's own cleanup missed every upload on PHP 8.4 and up, so the bridge's deletion had nothing to delete and /tmp still leaked on 8.4 runtimes. composer require bref/bref:^3.0.13 — the bridge's constraint will not force this for you if your lock file is already solving.
Cleanup is deferred, not immediate. Uploads are removed when the next request starts, not at the end of the current one. So a burst of traffic on a long-lived instance still temporarily holds two requests' worth of files in /tmp, and a single oversized upload is never cleaned mid-request. Size your handlers accordingly and prefer direct-to-S3 uploads.
Empty .env.local.php disables Dotenv entirely. That is the point — but it means any value you relied on from .env locally must be set as a Lambda environment variable (or in serverless.yml environment: / SSM parameters).
Cold starts are normal and frequent. AWS recycles containers several times a day and scales out aggressively. A warm cache removes the worst of it; the rest is autoload size. --classmap-authoritative combined with composer dump-autoload --optimize is worth the effort.
Version pinning matters around Symfony and Bref releases. 3.0.1 exists specifically to fix a Symfony 8.1 breaking change, 3.0.3 fixes the /tmp upload leak and bumps the minimum Bref to 3.0.13, and 3.0.x is the Bref 3 line while 2.0.x is the Bref 3-preview line. Match the bridge major to your Bref major and keep both minors current.
Reverting log format is a dependency change, not config. Require bref/monolog-bridge: ^1.0 to drop the request-ID prefix; there is no flag on the formatter bundle.
Debugging a handler resolution failure: run serverless invoke local -f sqsHandler — it reproduces the container boot path locally. If that works but production fails, the usual cause is a warm vs. cold cache directory mismatch or a private service.
Debugging a /tmp exhaustion: the bridge does not log its cleanup. Inspect ls -la /tmp from a debug handler or add a temporary error_log() in a request listener to see what is accumulating — Doctrine proxies, Monolog streams and UploadedFile temp names all show up there.
Extension points are narrow by design. BrefKernel::getWritableCacheDirectories() is effectively the only hook; everything else (runtime bootstrap, log formatting) is configured through serverless.yml handlers/layers or by registering the bundle. Don't subclass further without a strong reason — the bridge is deliberately thin and tracks Symfony internals closely.
Deploying into a subdirectory is supported since 1.0.3; older versions produced broken cache paths when the app wasn't at the package root.
How can I help you explore Laravel packages today?