Repository: brefphp/bref (language: php)

Issue:
Make bref 2 even lighter by making `async-aws/ssm` optional
Move dep to suggest and adjust code to to do a class_exists check and crash out with good DX if someone tries to resolve an env variable via ssm without installing the dep (https://github.com/brefphp/bref/blob/2.0.0-beta11/src/Secrets/Secrets.php#L90).
Log the  lambda request id in the timeout exception
So that it's easier to map this log to other log in tools like kibana /grafana 

Implementation:  make the  TimeOut exception take a second parameter (optional  so that it works in the local docker environment ?) which is the lambda request id coming from the context 

If you think it is a good idea, I can provide the PR
Get rid of `separateVendor: true` for Bref v2
I am considering removing the `separateVendor: true` feature for Bref 2.0 (https://bref.sh/docs/environment/php.html#separate-vendor-folder).

Motivation:

- this feature was contributed a while ago but it's never been maintained, it adds a lot of technical debt and bugs (e.g. last one is #1311)
- I'm not sure it is used a lot
- I'd actually recommend deploying using Docker images instead, as this provides flat cold starts (better performances)

Any opinions here? Anyone using it?


Discussion / hints:

Fix patch:
diff --git a/.gitattributes b/.gitattributes
index 4759f019f..9e70ad3cd 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,16 +4,16 @@ demo/ export-ignore
 docs/ export-ignore
 runtime/ export-ignore
 tests/ export-ignore
+utils/ export-ignore
 website/ export-ignore
 .gitattributes export-ignore
 .gitignore export-ignore
-.phpcs.xml.dist export-ignore
-.prettyci.composer.json export-ignore
+.phpcs.xml export-ignore
 couscous.yml export-ignore
 docker-compose.yml export-ignore
 Makefile export-ignore
-phpstan.neon.dist export-ignore
-phpunit.xml.dist export-ignore
+phpstan.neon export-ignore
+phpunit.xml export-ignore
 
 # Auto detect text files and perform LF normalization
 * text=auto
diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml
deleted file mode 100644
index f3e16d8f9..000000000
--- a/.github/workflows/build-docker.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-name: Build Docker images
-
-on:
-  pull_request:
-    paths:
-      - runtime/**
-
-jobs:
-  publish:
-    name: Building images
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v2
-
-      - name: Build images
-        run: make docker-images
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e55049d45..22177498a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,11 +16,11 @@ jobs:
         timeout-minutes: 15
         strategy:
             matrix:
-                php: [ '8.2', '8.1', '8.0', '7.4', '7.3' ]
+                php: [ '8.2', '8.1', '8.0' ]
                 dependency-version: [ '' ]
                 platform-reqs: [ '' ]
                 include:
-                    -   php: '7.3'
+                    -   php: '8.0'
                         dependency-version: '--prefer-lowest'
         steps:
             -   name: Checkout
@@ -81,7 +81,7 @@ jobs:
             -   name: Setup PHP
                 uses: shivammathur/setup-php@v2
                 with:
-                    php-version: 7.4
+                    php-version: 8.1
                     tools: composer:v2, cs2pr
                     coverage: none
             -   name: Cache Composer dependencies
diff --git a/.github/workflows/deploy-docker.yml b/.github/workflows/deploy-docker.yml
deleted file mode 100644
index fd089e126..000000000
--- a/.github/workflows/deploy-docker.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-name: Deploy Docker images
-
-on:
-  push:
-    tags:
-      - '*' # On all tags
-
-jobs:
-  publish:
-    name: Building images
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v2
-
-      - name: Get the version
-        id: get_version
-        run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
-
-      - name: Docker login
-        run: echo '${{ secrets.DOCKER_PASSWORD }}' | docker login --username ${{ secrets.DOCKER_USERNAME }} --password-stdin
-
-      - name: Build images
-        run: make docker-images
-
-      - name: Publish
-        env:
-          DOCKER_TAG: ${{ steps.get_version.outputs.VERSION }}
-        run: make publish-docker-images
diff --git a/.github/workflows/update-layer-versions.yml b/.github/workflows/update-layer-versions.yml
index c96943cb8..ecf9b03c2 100644
--- a/.github/workflows/update-layer-versions.yml
+++ b/.github/workflows/update-layer-versions.yml
@@ -1,7 +1,18 @@
 name: Update layer versions (Bref v2)
 
 # Triggered manually or automatically when new Bref layers published
-on: workflow_dispatch
+on:
+    workflow_dispatch:
+        inputs:
+            release_url:
+                required: true
+                type: string
+            release_html_url:
+                required: true
+                type: string
+            release_name:
+                required: true
+                type: string
 
 # Necessary to connect to AWS using OIDC
 # https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services
@@ -44,9 +55,11 @@ jobs:
                 uses: peter-evans/create-pull-request@v4
                 with:
                     commit-message: Update layer versions
-                    title: Updated AWS Lambda layers (Bref v2)
+                    title: Update Bref v2 Lambda layers [${{ inputs.release_name }}](${{ inputs.release_html_url }})
                     body: |
-                        New AWS Lambda layers [have been released](https://github.com/brefphp/aws-lambda-layers/releases) for Bref v2.
+                        New AWS Lambda layers [have been released](https://github.com/brefphp/aws-lambda-layers/releases) for Bref v2:
+                        
+                        [${{ inputs.release_name }}](${{ inputs.release_html_url }})
 
                         This PR was automatically generated to update the layer versions used by Bref.
                         After merging, a new Bref release needs to be created.
diff --git a/.phpcs.xml b/.phpcs.xml
index aa7f24ad0..7b6fa93b6 100644
--- a/.phpcs.xml
+++ b/.phpcs.xml
@@ -10,10 +10,6 @@
     <file>bref</file>
     <file>src</file>
     <file>tests</file>
-    <exclude-pattern>tests/Bridge/Symfony/var</exclude-pattern>
-    <exclude-pattern>tests/Bridge/Symfony/cache</exclude-pattern>
-    <exclude-pattern>tests/Bridge/Symfony/logs</exclude-pattern>
-    <exclude-pattern>tests/Bridge/Laravel/bootstrap/cache</exclude-pattern>
 
     <!-- Import the Doctrine coding standard -->
     <rule ref="Doctrine"/>
@@ -169,11 +165,8 @@
         <exclude-pattern type="relative">tests/*</exclude-pattern>
     </rule>
 
-
-    <!-- ########################-->
-    <!-- For PHP 7.4+ only       -->
-    <!-- ########################-->
-    <rule ref="SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint">
+    <!-- Bref contains self-contained PHP files mixing code and functions -->
+    <rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
         <severity>0</severity>
     </rule>
 
diff --git a/.prettyci.composer.json b/.prettyci.composer.json
deleted file mode 100644
index 4a12ee010..000000000
--- a/.prettyci.composer.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-    "require": {
-        "doctrine/coding-standard": "^8.0"
-    }
-}
diff --git a/Makefile b/Makefile
index 829d15ef5..a2bdaf022 100644
--- a/Makefile
+++ b/Makefile
@@ -6,37 +6,6 @@ trigger_runtimes:
 runtime_build_status:
 	aws codepipeline get-pipeline-state --name=bref-php-binary | jq ".stageStates[1].latestExecution.status"
 
-# Build the PHP runtimes
-runtimes:
-	cd runtime ; make publish
-
-# Build all docker images
-docker-images:
-	cd runtime ; make docker-images
-
-# Publish doocker images
-publish-docker-images: docker-images
-    # Make sure we have defined the docker tag
-	(test $(DOCKER_TAG)) && echo "Tagging images with \"${DOCKER_TAG}\"" || echo "You have to define environment variable DOCKER_TAG"
-	test $(DOCKER_TAG)
-
-	for image in \
-	  "bref/php-73" "bref/php-73-fpm" "bref/php-73-console" "bref/php-73-fpm-dev" \
-	  "bref/php-74" "bref/php-74-fpm" "bref/php-74-console" "bref/php-74-fpm-dev" \
-	  "bref/php-80" "bref/php-80-fpm" "bref/php-80-console" "bref/php-80-fpm-dev" \
-	  "bref/php-81" "bref/php-81-fpm" "bref/php-81-console" "bref/php-81-fpm-dev" \
-	  "bref/php-82" "bref/php-82-fpm" "bref/php-82-console" "bref/php-82-fpm-dev" \
-	  "bref/build-php-73" \
-	  "bref/build-php-74" \
-	  "bref/build-php-80" \
-	  "bref/build-php-81" \
-	  "bref/build-php-82" \
-	  "bref/fpm-dev-gateway"; \
-	do \
-		docker image tag $$image:latest $$image:${DOCKER_TAG} ; \
-		docker image push --all-tags $$image ; \
-	done
-
 # Generate and deploy the production version of the website using http://couscous.io
 website:
 	# See http://couscous.io/
@@ -61,21 +30,9 @@ demo:
 	serverless deploy
 
 layers.json:
-	php runtime/layers/layer-list.php
+	php utils/layers.json/update.php
 
 test-stack:
 	serverless deploy -c tests/serverless.tests.yml
 
-changelog:
-	docker run -it --rm -v "$(pwd)":/usr/local/src/your-app ferrarimarco/github-changelog-generator --user brefphp --project bref --output= --unreleased-only --token=$$GITHUB_TOKEN_READ --no-issues --usernames-as-github-logins --no-verbose
-
-# http://amazon-linux-2-packages.bref.sh/
-amazonlinux-package-list:
-	docker run --rm -it --entrypoint= public.ecr.aws/lambda/provided:al2 yum list --quiet --color=never > index.html
-	aws s3 cp index.html s3://amazon-linux-2-packages.bref.sh/ --content-type=text/plain
-	rm index.html
-
-getcomposer:
-	./runtime/helpers/install-composer.sh
-
-.PHONY: runtimes website website-preview website-assets demo layers.json test-stack changelog
+.PHONY: website website-preview website-assets demo layers.json test-stack
diff --git a/bref b/bref
index 56938acd7..493d6f74f 100755
--- a/bref
+++ b/bref
@@ -2,13 +2,6 @@
 <?php
 declare(strict_types=1);
 
-use Bref\Console\Command\Cli;
-use Bref\Console\Command\Dashboard;
-use Bref\Console\Command\Init;
-use Bref\Console\Command\Layers;
-use Bref\Console\Command\Local;
-use Symfony\Component\Console\Application;
-
 if (file_exists(__DIR__ . '/vendor/autoload.php')) {
     require_once __DIR__ . '/vendor/autoload.php';
 } elseif (file_exists(__DIR__ . '/../autoload.php')) {
@@ -19,16 +12,71 @@ if (file_exists(__DIR__ . '/vendor/autoload.php')) {
     require_once __DIR__ . '/../../autoload.php';
 }
 
-$app = new Application('Deploy serverless PHP applications');
-$app->add(new Init());
-$app->add(new Cli());
-$app->add(new Local());
-$app->add(new Dashboard());
-$app->add(new Layers());
-
 telemetry();
 
-$app->run();
+switch ($argv[1] ?? '') {
+    case 'init':
+        require_once __DIR__ . '/src/Cli/init.php';
+        init();
+        break;
+    case 'cli':
+        cliWarning();
+        error('Since Bref 2.0, the "bref cli" command has been moved. Read https://bref.sh/docs/runtimes/console.html#usage');
+    case 'layers':
+        cliWarning();
+        echo "Bref layer ARNs can be found here: https://runtimes.bref.sh/\n\n";
+        echo "If you are using Serverless Framework, you can also run the 'serverless bref:layers' command.\n";
+        exit(1);
+    case 'local':
+        cliWarning();
+        error('Since Bref 2.0, the "bref local" command has been moved. Read https://bref.sh/docs/function/local-development.html\n');
+    case 'dashboard':
+        cliWarning();
+        echo "The Bref Dashboard is now available as a desktop application.\n\n";
+        echo "Check out https://dashboard.bref.sh/\n";
+        exit(1);
+    case '':
+        help();
+        break;
+    default:
+        error('Unknown command');
+}
+
+function help()
+{
+    echo "Bref initialization command\n\n";
+    echo "Run 'bref init' to get started!\n";
+    exit(0);
+}
+
+/**
+ * @return never-return
+ */
+function error(string $message): void
+{
+    echo "\033[31m⨯ $message\033[0m" . PHP_EOL;
+    exit(1);
+}
+
+function warning(string $message): void
+{
+    echo "\033[33m$message\033[0m" . PHP_EOL;
+}
+
+function success(string $message): void
+{
+    echo green($message) . PHP_EOL;
+}
+
+function green(string $message): string
+{
+    return "\033[32m$message\033[0m";
+}
+
+function cliWarning(): void
+{
+    warning("Warning: the 'vendor/bin/bref' CLI has been removed in Bref 2." . PHP_EOL);
+}
 
 /**
  * Bref telemetry to estimate the number of users and which commands are most used.
@@ -41,7 +89,8 @@ $app->run();
  *
  * About UDP: https://en.wikipedia.org/wiki/User_Datagram_Protocol
  */
-function telemetry() {
+function telemetry(): void
+{
     global $argv;
     // Respect the serverless framework env variable
     if ($_SERVER['SLS_TELEMETRY_DISABLED'] ?? false) {
@@ -68,7 +117,7 @@ function telemetry() {
 
     $message = json_encode([
         'cli' => 'vendor/bin/bref',
-        'v' => 1, // Bref version
+        'v' => 2, // Bref version
         'c' => $argv[1] ?? '',
         'ci' => $ci,
         // anonymous user ID created by the Serverless Framework
diff --git a/composer.json b/composer.json
index 8ecc70eeb..41dc5a8ba 100644
--- a/composer.json
+++ b/composer.json
@@ -16,33 +16,32 @@
         }
     },
     "bin": [
-        "bref"
+        "bref",
+        "src/bref-local"
     ],
     "require": {
-        "php": ">=7.3.0",
+        "php": ">=8.0",
         "ext-curl": "*",
         "ext-json": "*",
-        "symfony/console": "^3.0|^4.0|^5.0|^6.0",
-        "symfony/filesystem": "^3.1|^4.0|^5.0|^6.0",
         "symfony/process": "^4.2|^5.0|^6.0",
         "psr/http-message": "^1.0",
-        "hollodotme/fast-cgi-client": "^3.0",
+        "hollodotme/fast-cgi-client": "^3.0.1",
         "riverline/multipart-parser": "^2.0.6",
         "psr/http-server-handler": "^1.0",
-        "async-aws/lambda": "^1.0",
         "nyholm/psr7": "^1.2",
-        "psr/container": "^1.0|^2.0",
-        "symfony/yaml": "^3.1|^4.0|^5.0|^6.0",
-        "symfony/http-client": "^4.4|^5.0|^6.0"
+        "psr/container": "^1.0|^2.0"
     },
     "require-dev": {
         "aws/aws-sdk-php": "^3.172",
         "async-aws/core": "^1.0",
+        "async-aws/lambda": "^1.0",
+        "bref/secrets-loader": "^1.0",
         "phpunit/phpunit": "^9.0",
         "dms/phpunit-arraysubset-asserts": "^0.2.0",
         "doctrine/coding-standard": "^8.0",
         "phpstan/phpstan": "^1.0",
-        "guzzlehttp/guzzle": "^6.3"
+        "guzzlehttp/guzzle": "^6.3",
+        "symfony/console": "^3.0|^4.0|^5.0|^6.0"
     },
     "scripts": {
         "test": [
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 4ac603e70..000000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-version: "3.5"
-
-services:
-    console:
-        image: bref/php-74
-        volumes:
-            - .:/var/task:ro # Read only, like a lambda function
-        entrypoint: php
-        command: demo/console.php
-
-    web:
-        image: bref/fpm-dev-gateway
-        ports:
-            - '8000:80'
-        volumes:
-            - .:/var/task:ro # Read only, like a lambda function
-        depends_on:
-            - php
-        environment:
-            HANDLER: demo/http.php
-    php:
-        image: bref/php-74-fpm-dev
-        volumes:
-            - .:/var/task:ro # Read only, like a lambda function
diff --git a/docs/environment/php.md b/docs/environment/php.md
index 6d9ceea43..3fb448f77 100644
--- a/docs/environment/php.md
+++ b/docs/environment/php.md
@@ -119,10 +119,7 @@ extension=pdo_pgsql
 
 ### Extra extensions
 
-Due to space limitations in AWS Lambda, Bref cannot provide every possible extension.
-There are a list of additional PHP extensions that can be installed as a separate
-layer. They are less common extensions or extensions that for some reasons should
-not be in the normal Bref layer.
+Due to space limitations in AWS Lambda, Bref runtimes cannot include every possible PHP extensions. These additional PHP extensions can be included as separate AWS Lambda layers.
 
 All extra PHP extensions are found in [brefphp/extra-php-extensions](https://github.com/brefphp/extra-php-extensions).
 
@@ -139,13 +136,13 @@ To create your custom layer, you will need to:
 - compile the extension (and any required libraries) in the same environment as AWS Lambda and Bref
 - include the compiled extension (and required libraries) in a layer
 - upload the layer to AWS Lambda
-- include it in your project **after the Bref layer**
+- include the in your project
 - enable the extension in a custom `php.ini`
 
 To compile the extension, Bref provides the `bref/build-php-*` Docker images. Here is an example with Blackfire:
 
 ```dockerfile
-FROM bref/build-php-74
+FROM bref/build-php-80
 
 RUN curl -A "Docker" -o /tmp/blackfire.so -L -s "https://packages.blackfire.io/binaries/blackfire-php/1.42.0/blackfire-php-linux_amd64-php-74.so"
 
@@ -177,17 +174,3 @@ provider:
 ```
 
 The path must start with `/var/task`, which is the directory where projects are installed on AWS Lambda.
-
-## Separate vendor folder
-
-Bref has the ability to shrink the Lambda deployment archive by separating the vendor folder from the applications code and injecting it later on into the Lambda function. This allows Bref to circumvent Lambda file size limitations.
-
-By setting the option `separateVendor` in the `custom.bref` block to `true`, you can enable this feature.
-
-```yaml
-custom:
-    bref:
-        separateVendor: true
-```
-
-> Using this option changes the path of the vendor directory to `/tmp/vendor`. References in your code to the composer autoloader therefore must be adjusted to `/tmp/vendor/autoload.php`.
diff --git a/docs/environment/serverless-yml.md b/docs/environment/serverless-yml.md
index 5db7267dc..db9ca7dc5 100644
--- a/docs/environment/serverless-yml.md
+++ b/docs/environment/serverless-yml.md
@@ -15,7 +15,6 @@ service: app
 
 provider:
     name: aws
-    runtime: provided.al2
 
 plugins:
     - ./vendor/bref/bref
@@ -23,8 +22,7 @@ plugins:
 functions:
     foo:
         handler: index.php
-        layers:
-            - ${bref:layer.php-74} # PHP
+        runtime: php-81
 
 resources:
     Resources:
@@ -67,18 +65,14 @@ The `provider` section also lets us configure global options on all functions:
 ```yaml
 provider:
     name: aws
+    runtime: php-81
     timeout: 10
-    runtime: provided.al2
 
 functions:
     foo:
         handler: foo.php
-        layers:
-            - ${bref:layer.php-74}
     bar:
         handler: bar.php
-        layers:
-            - ${bref:layer.php-74}
 
 # ...
 ```
@@ -92,16 +86,12 @@ provider:
 functions:
     foo:
         handler: foo.php
+        runtime: php-81
         timeout: 10
-        runtime: provided.al2
-        layers:
-            - ${bref:layer.php-74}
     bar:
         handler: bar.php
+        runtime: php-81
         timeout: 10
-        runtime: provided.al2
-        layers:
-            - ${bref:layer.php-74}
 
 # ...
 ```
@@ -115,7 +105,7 @@ plugins:
 
 [Serverless plugins](https://serverless.com/framework/docs/providers/aws/guide/plugins/) are JavaScript plugins that extend the behavior of the Serverless framework.
 
-Bref provides a plugin via the Composer package, which explains why the path is a relative path into the `vendor` directory. This plugin provides [variables to easily use Bref layers](../runtimes/#usage), it is necessary to include it for the `${bref:layer.xxx}` variables to work.
+Bref provides a plugin via the Composer package, which explains why the path is a relative path into the `vendor` directory. This plugin provides [support for the Bref runtimes and layers](../runtimes/#usage), it is necessary to include it.
 
 Most other Serverless plugins [are installed via `npm`](https://serverless.com/framework/docs/providers/aws/guide/plugins/).
 
@@ -146,17 +136,15 @@ Read more about the `package` configuration [in the serverless.yml documentation
 functions:
     foo:
         handler: foo.php
-        layers:
-            - ${bref:layer.php-74}
+        runtime: php-81
     bar:
         handler: bar.php
-        layers:
-            - ${bref:layer.php-74}
+        runtime: php-81
 ```
 
 Functions are AWS Lambda functions. You can find all options available [in this Serverless documentation page](https://serverless.com/framework/docs/providers/aws/guide/functions/).
 
-Note that it is possible to mix PHP functions with functions written in other languages. The PHP support provided by Bref works via the AWS Lambda layers.
+Note that it is possible to mix PHP functions with functions written in other languages in the same `serverless.yml` config.
 
 ### Permissions
 
@@ -188,8 +176,6 @@ provider:
 
 If you only want to define some permissions **per function**, instead of globally (ie: in the provider), you should install and enable the Serverless plugin [`serverless-iam-roles-per-function`](https://github.com/functionalone/serverless-iam-roles-per-function) and then use the `iamRoleStatements` at the function definition block.
 
-If you are using the custom parameter `separateVendor: true` with **per function** permissions, you should also add the `iamRoleStatementsInherit: true` parameter at the function definition block otherwise the Lambda won't have permissions to retrieve the vendor directory in the S3 bucket anymore.
-
 ## Resources
 
 ```yaml
diff --git a/docs/environment/storage.md b/docs/environment/storage.md
index e5b51f37f..564366db0 100644
--- a/docs/environment/storage.md
+++ b/docs/environment/storage.md
@@ -4,6 +4,20 @@ current_menu: storage
 introduction: Learn how to store data and files in serverless PHP applications running on AWS Lambda.
 ---
 
+Here is a simplified overview of the filesystem on AWS Lambda:
+
+```bash
+/opt/
+    # Where Lambda runtimes and layers (like Bref) are unzipped
+/var/task/
+    # Where your application code is unzipped
+/tmp/
+    # Temporary files
+...
+```
+
+The `/var/task` directory is [limited to 250MB](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html#function-configuration-deployment-and-execution). If you hit that limit, you can deploy [via Docker images instead](../web-apps/docker.md).
+
 The filesystem on AWS Lambda **is read-only**, except for the `/tmp` directory.
 
 On top of that, the filesystem is not shared between instances of a lambda when it scales up. For example a file `/tmp/foo.json` will not be shared across instances of the same lambda. Since a lambda can scale up or down at any time, data in the `/tmp` directory can be lost.
diff --git a/docs/environment/variables-create-secret.png b/docs/environment/variables-create-secret.png
new file mode 100644
index 000000000..45406f962
Binary files /dev/null and b/docs/environment/variables-create-secret.png differ
diff --git a/docs/environment/variables.md b/docs/environment/variables.md
index aa5d5d075..19ed4d31a 100644
--- a/docs/environment/variables.md
+++ b/docs/environment/variables.md
@@ -33,11 +33,15 @@ functions:
 
 ## Secrets
 
-Secrets (API tokens, database passwords, etc.) should not be defined in `serverless.yml` and committed into your git repository.
+Secrets (API tokens, database passwords, etc.) should not be defined in `serverless.yml` or committed into your git repository.
 
-Instead you can use the [SSM parameter store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-paramstore.html), a free service provided by AWS.
+Instead, you can use the [SSM parameter store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-paramstore.html), a free service provided by AWS.
 
-To create a parameter, you can do it via the [AWS SSM console](https://console.aws.amazon.com/systems-manager/parameters) or the [Bref Dashboard](https://dashboard.bref.sh/).
+### Creating secrets
+
+To create a parameter, you can do it via the [AWS SSM console](https://console.aws.amazon.com/systems-manager/parameters) or the [Bref Dashboard](https://dashboard.bref.sh/):
+
+![](./variables-create-secret.png)
 
 You can also do it in the CLI via the following command:
 
@@ -52,35 +56,97 @@ aws ssm put-parameter --region us-east-1 --name '//my-app\my-parameter' --type S
 
 It is recommended to prefix the parameter name with your application name, for example: `/my-app/my-parameter`.
 
-To import the SSM parameter into an environment variable you can use the [`${ssm:<parameter>}` syntax](https://serverless.com/blog/serverless-secrets-api-keys/):
+### Retrieving secrets
+
+You can inject a secret in an environment variable:
+
+- either at **deployment time** (simplest)
+- or at **runtime** (more secure)
+
+#### At deployment time
+
+Use the [`${ssm:<parameter>}` syntax](https://serverless.com/blog/serverless-secrets-api-keys/) to have the variable be replaced by the secret value on deployment:
 
 ```yaml
 provider:
     # ...
     environment:
         MY_PARAMETER: ${ssm:/my-app/my-parameter}
+
+        # If you need to set a different value per stage:
+        OTHER_PARAMETER: ${ssm:/my-app/${sls:stage}/my-parameter}
 ```
 
+Advantages:
+
+- Simpler, it just works.
+
+Disadvantages:
+
+- The user deploying must be allowed to retrieve the secret value.
+- The secret value will be set in clear text in the Lambda function configuration (anyone who can access the function can also view the value).
+
+#### At runtime
+
+Alternatively, Bref can fetch the secret values at runtime when the Lambda function starts (aka the "cold start"). To use that feature, we **must install** the `bref/secrets-loader` package:
+
+```
+composer require bref/secrets-loader
+```
+
+To use it, the environment variable should contain the path to the SSM parameter prefixed with `bref-ssm:`. We also need to authorize Lambda to retrieve the parameter. For example:
+
+```yaml
+provider:
+    # ...
+    environment:
+        MY_PARAMETER: bref-ssm:/my-app/my-parameter
+    iam:
+        role:
+            statements:
+                # Allow our Lambda functions to retrieve the parameter from SSM
+                -   Effect: Allow
+                    Action: ssm:GetParameters
+                    Resource: 'arn:aws:ssm:${aws:region}:${aws:accountId}:parameter/my-app/my-parameter'
+                    # If you want to be more generic you can uncomment the line below instead.
+                    # But it authorizes retrieving *any* SSM parameter, which is less secure.
+                    #Resource: '*'
+```
+
+On a cold start, Bref automatically checks all environment variables that start with `bref-ssm:` and will resolve the values by calling the AWS SSM API. It adds a very small latency overhead for the first request (note: all SSM values are fetched in a single API call).
+
+Advantages:
+
+- The value doesn't have to be accessible by the user deploying.
+- The value is not stored in plain text in the AWS console.
+
+Disadvantages:
+
+- More complex configuration.
+- Small added latency to cold starts.
+
 ### An alternative: AWS Secrets Manager
 
-As an alternative you can also store secrets in [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). This solution, while very similar to SSM, will provide:
+As an alternative, you can store secrets in [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). This solution, while very similar to SSM, will provide:
 
 - better permission management using IAM
 - JSON values, allowing to store multiple values in one parameter
 
-However Secrets Manager is not free: [pricing details](https://aws.amazon.com/secrets-manager/pricing/).
+However, Secrets Manager is not free: [pricing details](https://aws.amazon.com/secrets-manager/pricing/).
+
+SSM is good enough for most projects.
 
 ## Local development
 
-When [developing locally using `vendor/bin/bref local`](/docs/local-development.md), you can set environment variables using bash:
+When [developing locally using `serverless bref:local`](/docs/local-development.md), you can set environment variables using bash:
 
 ```bash
-VAR1=val1 VAR2=val2 vendor/bin/bref local <function>
+VAR1=val1 VAR2=val2 serverless bref:local -f <function>
 
 # Or using `export`:
 export VAR1=val1
 export VAR2=val2
-vendor/bin/bref local <function>
+serverless bref:local -f <function>
 ```
 
 ## Learn more
diff --git a/docs/frameworks/laravel.md b/docs/frameworks/laravel.md
index f4da78926..4dbb837ca 100644
--- a/docs/frameworks/laravel.md
+++ b/docs/frameworks/laravel.md
@@ -26,25 +26,25 @@ php artisan vendor:publish --tag=serverless-config
 
 ### How it works
 
-By default, the Laravel-Bref package will automatically configure Laravel to work on AWS Lambda
+By default, the Laravel-Bref package will automatically configure Laravel to work on AWS Lambda.
 
-If you are curious, the package will:
+If you are curious, the package will automatically:
 
 - enable the `stderr` log driver, to send logs to CloudWatch ([read more about logs](../environment/logs.md))
-- enable the [`cookie` session driver](https://laravel.com/docs/session#configuration)
-    - if you don't need sessions (e.g. for an API), you can manually set `SESSION_DRIVER=array` in `.env`
-    - if you prefer, you can configure sessions to be store in database or Redis
-- move the cache directory to `/tmp` (because the default storage directory is read-only on Lambda)
+- enable the [`cookie` session driver](https://laravel.com/docs/session#configuration) (if you prefer, you can configure sessions to be stored in database, DynamoDB or Redis)
+- move the storage directory to `/tmp` (because the default storage directory is read-only on Lambda)
 - adjust a few more settings ([have a look at the `BrefServiceProvider` for details](https://github.com/brefphp/laravel-bridge/blob/master/src/BrefServiceProvider.php))
 
 ## Deployment
 
-We do not want to deploy caches that were generated on our machine (because paths will be different on AWS Lambda). Let's clear them before deploying:
+We do not want to deploy "dev" caches that were generated on our machine (because paths will be different on AWS Lambda). Let's clear them before deploying:
 
 ```bash
 php artisan config:clear
 ```
 
+When running in AWS Lambda, the Laravel application will automatically cache its configuration when booting. You don't need to run `php artisan config:cache` before deploying.
+
 Let's deploy now:
 
 ```bash
@@ -67,106 +67,56 @@ Follow [the deployment guide](/docs/deploy.md#deploying-for-production) for more
 
 In case your application is showing a blank page after being deployed, [have a look at the logs](../environment/logs.md).
 
-## Trusted proxies
-
-Because Laravel is executed through API Gateway, the `Host` header is set to the API Gateway host name. Helper functions such as `redirect()` will use this incorrect domain name. The correct domain name is set on the `X-Forwarded-Host` header.
-
-To get Laravel to use `X-Forwarded-Host` instead, edit the `App\Http\Middleware\TrustProxies` middleware and set `$proxies` to the `*` wildcard:
-
-```php
-class TrustProxies extends Middleware
-{
-    // ...
-    protected $proxies = '*';
-```
-
-Read more [in the official Laravel documentation](https://laravel.com/docs/8.x/requests#configuring-trusted-proxies).
-
-## Caching
-
-By default, the Bref bridge will move Laravel's cache directory to `/tmp` to avoid issues with the default cache directory that is read-only.
-
-The `/tmp` directory isn't shared across Lambda instances: while this works, this isn't the ideal solution for production workloads.
-If you plan on actively using the cache, or anything that uses it (like API rate limiting), you should instead use Redis or DynamoDB.
-
-### Using DynamoDB
-
-To use DynamoDB as a cache store, change this configuration in `config/cache.php`
-
-```diff
-  # config/cache.php
-  'dynamodb' => [
-      'driver' => 'dynamodb',
-      'key' => env('AWS_ACCESS_KEY_ID'),
-      'secret' => env('AWS_SECRET_ACCESS_KEY'),
-      'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
-      'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
-      'endpoint' => env('DYNAMODB_ENDPOINT'),
-+     'attributes' => [
-+         'key' => 'id',
-+         'expiration' => 'ttl',
-+     ]  
-  ],
-```
-
-Then follow [this section of the documentation](/docs/environment/storage.md#deploying-dynamodb-tables) to deploy your DynamoDB table using the Serverless Framework.
-
 ## Laravel Artisan
 
 As you may have noticed, we define a function named "artisan" in `serverless.yml`. That function is using the [Console runtime](/docs/runtimes/console.md), which lets us run Laravel Artisan on AWS Lambda.
 
 For example, to execute an `artisan` command on Lambda for the above configuration, run the below command.
 
-```
-vendor/bin/bref cli bref-demo-laravel-artisan <bref options> -- <your command, your options>
+```sh
+serverless bref:cli --args="<artisan command and options>"
 ```
 
 For more details follow [the "Console" guide](/docs/runtimes/console.md).
 
 ## Assets
 
-To deploy Laravel websites, assets need to be served from AWS S3. The easiest approach is to use the
-<a href="https://github.com/getlift/lift/blob/master/docs/server-side-website.md">Server-side website construct of the Lift plugin</a>.
+To deploy Laravel websites, assets need to be served from AWS S3. The easiest approach is to use the [Server-side website construct of the Lift plugin](https://github.com/getlift/lift/blob/master/docs/server-side-website.md).
 
-This will deploy a Cloudfront distribution that will act as a proxy: it will serve
-static files directly from S3 and will forward everything else to Lambda. This is very close
-to how traditional web servers like Apache or Nginx work, which means your application doesn't need to change!
-For more details, see <a href="https://github.com/getlift/lift/blob/master/docs/server-side-website.md#how-it-works">the official documentation</a>.
+This will deploy a Cloudfront distribution that will act as a proxy: it will serve static files directly from S3 and will forward everything else to Lambda. This is very close to how traditional web servers like Apache or Nginx work, which means your application doesn't need to change! For more details, read [the official documentation](https://github.com/getlift/lift/blob/master/docs/server-side-website.md#how-it-works).
 
-First install the plugin
+First install the plugin:
 
 ```bash
 serverless plugin install -n serverless-lift
 ```
 
-Then add this configuration to your `serverless.yml` file.
+Then add this configuration to your `serverless.yml` file:
 
 ```yaml
-...
 service: laravel
-
 provider:
-  ...
+    # ...
 
-plugins:
-  - ./vendor/bref/bref
-  - serverless-lift
-    
 functions:
-  ...
+    # ...
+
+plugins:
+    - ./vendor/bref/bref
+    - serverless-lift
 
 constructs:
-  website:
-    type: server-side-website
-    assets:
-      '/js/*': public/js
-      '/css/*': public/css
-      '/favicon.ico': public/favicon.ico
-      '/robots.txt': public/robots.txt
-      # add here any file or directory that needs to be served from S3
+    website:
+        type: server-side-website
+        assets:
+            '/js/*': public/js
+            '/css/*': public/css
+            '/favicon.ico': public/favicon.ico
+            '/robots.txt': public/robots.txt
+            # add here any file or directory that needs to be served from S3
 ```
 
-Before deploying, compile your assets using Laravel Mix.
+Before deploying, compile your assets:
 
 ```bash
 npm run prod
@@ -175,8 +125,7 @@ npm run prod
 Now deploy your website using `serverless deploy`. Lift will create all required resources and take care of
 uploading your assets to S3 automatically.
 
-For more details, see the [Websites section](/docs/websites.md) of this documentation
-and the official <a href="https://github.com/getlift/lift/blob/master/docs/server-side-website.md">Lift documentation</a>.
+For more details, see the [Websites section](/docs/websites.md) of this documentation and the official [Lift documentation](https://github.com/getlift/lift/blob/master/docs/server-side-website.md).
 
 ### Assets in templates
 
@@ -195,22 +144,17 @@ If your templates reference some assets via direct path, you should edit them to
 
 ## File storage on S3
 
-Laravel has a [filesystem abstraction](https://laravel.com/docs/filesystem) that lets us easily change where files are stored. When running on Lambda, you will need to use the `s3` adapter to store files on AWS S3. To do this, configure you production `.env` file:
-
-```dotenv
-# .env
-FILESYSTEM_DISK=s3
-```
+Laravel has a [filesystem abstraction](https://laravel.com/docs/filesystem) that lets us easily change where files are stored. When running on Lambda, you will need to use the `s3` adapter to store files on AWS S3.
 
-Next, we need to create our bucket via `serverless.yml`:
+To do this, set `FILESYSTEM_DISK: s3` either in `serverless.yml` or your production `.env` file. We can also create an S3 bucket via `serverless.yml` directly:
 
 ```yaml
-...
-
+# ...
 provider:
-    ...
+    # ...
     environment:
         # environment variable for Laravel
+        FILESYSTEM_DISK: s3
         AWS_BUCKET: !Ref Storage
     iam:
         role:
@@ -224,25 +168,12 @@ provider:
 
 resources:
     Resources:
+        # Create our S3 storage bucket using CloudFormation
         Storage:
             Type: AWS::S3::Bucket
 ```
 
-Because [of a misconfiguration shipped in Laravel](https://github.com/laravel/laravel/pull/5138), the S3 authentication will not work out of the box. You will need to add this line in `config/filesystems.php`:
-
-```diff
-        's3' => [
-            'driver' => 's3',
-            'key' => env('AWS_ACCESS_KEY_ID'),
-            'secret' => env('AWS_SECRET_ACCESS_KEY'),
-+           'token' => env('AWS_SESSION_TOKEN'),
-            'region' => env('AWS_DEFAULT_REGION'),
-            'bucket' => env('AWS_BUCKET'),
-            'url' => env('AWS_URL'),
-        ],
-```
-
-That's it! The `'AWS_ACCESS_KEY_ID'`, `'AWS_SECRET_ACCESS_KEY'` and `'AWS_SESSION_TOKEN'` variables are defined automatically on AWS Lambda, you don't have to define them.
+That's it! The AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN) are set automatically in AWS Lambda, you don't have to define them.
 
 ### Public files
 
@@ -250,9 +181,9 @@ Laravel has a [special disk called `public`](https://laravel.com/docs/filesystem
 
 Again, those files cannot be stored on Lambda, i.e. they cannot be stored in the default `storage/app/public` directory. You need to store those files on S3.
 
-> Do not run `php artisan storage:link` on AWS Lambda: it is now useless, and it will fail because the filesystem is read-only on Lambda.
+> Do not run `php artisan storage:link` in AWS Lambda: it is now useless, and it will fail because the filesystem is read-only in Lambda.
 
-To store public files on S3, you could simply replace the disk in the code:
+To store public files on S3, you could replace the disk in the code:
 
 ```diff
 - Storage::disk('public')->put('avatars/1', $fileContents);
@@ -310,7 +241,7 @@ but doing this will not let your application work locally. A better solution, bu
     ],
 ```
 
-You can now configure the `public` disk to use S3 by changing your production `.env`:
+You can now configure the `public` disk to use S3 by changing `serverless.yml` or your production `.env`:
 
 ```dotenv
 FILESYSTEM_DISK=s3
@@ -319,9 +250,161 @@ FILESYSTEM_DISK_PUBLIC=s3
 
 ## Laravel Queues
 
-It is possible to run Laravel Queues on AWS Lambda using [Amazon SQS](https://aws.amazon.com/sqs/).
+To run Laravel Queues on AWS Lambda using [Amazon SQS](https://aws.amazon.com/sqs/), we don't want to run the `php artisan queue:work` command. Instead, we create a function that is invoked immediately when there are new jobs to process.
+
+To create the SQS queue (and the permissions for the Lambda functions to read/write to it), we can either do that manually, or use `serverless.yml`.
+
+To make things simpler, we will use the [Serverless Lift](https://github.com/getlift/lift) plugin to create and configure the SQS queue.
+
+First install the Lift plugin:
+
+```bash
+serverless plugin install -n serverless-lift
+```
+
+Then use <a href="https://github.com/getlift/lift/blob/master/docs/queue.md">the Queue construct</a> in `serverless.yml`:
+
+```yml
+provider:
+    # ...
+    environment:
+        # ...
+        QUEUE_CONNECTION: sqs
+        SQS_QUEUE: ${construct:jobs.queueUrl}
+
+functions:
+    # ...
+
+constructs:
+    jobs:
+        type: queue
+        worker:
+            handler: Bref\LaravelBridge\Queue\QueueHandler
+            runtime: php-81
+            timeout: 60 # seconds
+```
+
+We define Laravel environment variables in `provider.environment` (this could also be done in the deployed `.env` file):
+
+- `QUEUE_CONNECTION: sqs` enables the SQS queue connection
+- `SQS_QUEUE: ${construct:jobs.queueUrl}` passes the URL of the created SQS queue
+
+If you want to create the SQS queue manually, you will need to set these variables. AWS credentials (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`) are automatically set up with the appropriate permissions for Laravel to use the SQS queue.
+
+That's it! Anytime a job is pushed to Laravel Queues, it will be sent to SQS, and SQS will invoke our "worker" function so that it is processed.
+
+> **Note**:
+> 
+> In the example above, we set the full SQS queue URL in the `SQS_QUEUE` variable.
+> 
+If you only set the queue name (which is also valid), you need to set the `SQS_PREFIX` environment variable too. For example: `SQS_PREFIX: "https://sqs.${aws:region}.amazonaws.com/${aws:accountId}"`.
+
+### How it works
+
+When integrated with AWS Lambda, SQS has a built-in retry mechanism and storage for failed messages. These features work slightly differently than Laravel Queues. The "Bref for Laravel" integration does **not** use these SQS features.
+
+Instead, "Bref for Laravel" makes all the feature of Laravel Queues work out of the box, just like on any server. Read more in [the Laravel Queues documentation](https://laravel.com/docs/latest/queues).
+
+> **Note:** the "Bref-Laravel bridge" v1 used to do the opposite. We changed that behavior in Bref v2 in order to make the experience smoother for Laravel users.
+
+## Laravel Octane
+
+To run the HTTP application with [Laravel Octane](https://laravel.com/docs/10.x/octane) instead of PHP-FPM, change the following options in the `web` function:
+
+```yml
+functions:
+    web:
+        handler: Bref\LaravelBridge\Http\OctaneHandler
+        runtime: php-81
+        environment:
+            BREF_LOOP_MAX: 250
+        # ...
+```
+
+Keep the following details in mind:
+
+- Laravel Octane does not need Swoole or RoadRunner on AWS Lambda, so it is not possible to use Swoole-specific features.
+- Octane keeps Laravel booted in a long-running process, [beware of memory leaks](https://laravel.com/docs/10.x/octane#managing-memory-leaks).
+- `BREF_LOOP_MAX` specifies the number of HTTP requests handled before the PHP process is restarted (and the memory is cleared).
+
+### Persistent database sessions
+
+If you're using PostgreSQL 9.6 or newer, you can take advantage of persistent database sessions.
+
+First set [`idle_in_transaction_session_timeout`](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT) either in your RDS database's parameter group, or on a specific database itself.
+
+```sql
+ALTER DATABASE SET idle_in_transaction_session_timeout = '10000' -- 10 seconds in ms
+```
+
+Lastly, set the `OCTANE_PERSIST_DATABASE_SESSIONS` environment variable.
+
+```yml
+functions:
+    web:
+      handler: Bref\LaravelBridge\Http\OctaneHandler
+      runtime: php-81
+      environment:
+          BREF_LOOP_MAX: 250
+          OCTANE_PERSIST_DATABASE_SESSIONS: 1
+        # ...
+```
+
+## Caching
+
+By default, the Bref bridge will move Laravel's storage and cache directories to `/tmp`. This is because all the filesystem except `/tmp` is read-only.
+
+Note that the `/tmp` directory isn't shared across Lambda instances. If you Lambda function scales up, the cache will be empty in new instances (or after a deployment).
+
+If you want the cache to be shared across all Lambda instances, for example if your application caches a lot of data or if you use it for locking mechanisms (like API rate limiting), you can instead use Redis or DynamoDB.
+
+DynamoDB is the easiest to set up and is "pay per use". Redis is a bit more complex as it requires a VPC and managing instances, but offers slightly faster response times.
+
+### Using DynamoDB
+
+To use DynamoDB as a cache store, change this configuration in `config/cache.php`:
+
+```diff
+    # config/cache.php
+    'dynamodb' => [
+        'driver' => 'dynamodb',
+        'key' => env('AWS_ACCESS_KEY_ID'),
+        'secret' => env('AWS_SECRET_ACCESS_KEY'),
+        'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
+        'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
+        'endpoint' => env('DYNAMODB_ENDPOINT'),
++       'attributes' => [
++           'key' => 'id',
++           'expiration' => 'ttl',
++       ]
+    ],
+```
+
+Then follow [this section of the documentation](/docs/environment/storage.md#deploying-dynamodb-tables) to deploy your DynamoDB table using the Serverless Framework.
+
+## Maintenance mode
+
+Similar to the `php artisan down` command, you may put your app into maintenance mode. All that's required is setting the `MAINTENANCE_MODE` environment variable:
+
+```yml
+provider:
+    environment:
+        MAINTENANCE_MODE: ${param:maintenance, null}
+```
+
+You can then deploy:
+
+```bash
+# Full deployment (goes through CloudFormation):
+serverless deploy --param="maintenance=1"
+
+# Or quick update of the functions config only:
+serverless deploy function --function=web --update-config --param="maintenance=1"
+serverless deploy function --function=artisan --update-config --param="maintenance=1"
+serverless deploy function --function=<function-name> --update-config --param="maintenance=1"
+```
 
-A dedicated Bref package is available for this: [bref/laravel-bridge](https://github.com/brefphp/laravel-bridge).
+To take your app out of maintenance mode, redeploy without the `--param="maintenance=1"` option.
 
 ## Laravel Passport
 
@@ -355,8 +438,8 @@ Instead, here is what you need to do:
 - Finally, you can create the tokens (which is the second part of the `passport:install` command):
 
    ```bash
-   vendor/bin/bref cli <artisan-function-name> -- passport:client --personal --name 'Laravel Personal Access Client'
-   vendor/bin/bref cli <artisan-function-name> -- passport:client --password --name 'Laravel Personal Access Client'
+   serverless bref:cli --args="passport:client --personal --name 'Laravel Personal Access Client'"
+   serverless bref:cli --args="passport:client --password --name 'Laravel Personal Access Client'"
    ```
 
 All these steps were replacements of running the `passport:install` command [from the Passport documentation](https://laravel.com/docs/passport#installation).
diff --git a/docs/function/cron.md b/docs/function/cron.md
index a7092854f..8404b3118 100644
--- a/docs/function/cron.md
+++ b/docs/function/cron.md
@@ -13,8 +13,7 @@ AWS Lambda lets us run [PHP functions](/docs/runtimes/function.md) as cron tasks
 functions:
     console:
         handler: function.php
-        layers:
-            - ${bref:layer.php-74}
+        runtime: php-81
         events:
             - schedule: rate(1 hour)
 ```
diff --git a/docs/function/handlers.md b/docs/function/handlers.md
index 6f8e8bd4c..891c4168e 100644
--- a/docs/function/handlers.md
+++ b/docs/function/handlers.md
@@ -239,14 +239,12 @@ Since a handler is a controller for a specific route, we can use API Gateway's r
 functions:
     create-article:
         handler: create-article-handler.php
-        layers:
-            - ${bref:layer.php-74}
+        runtime: php-81
         events:
             - httpApi: 'POST /articles'
     get-article:
         handler: get-article-handler.php
-        layers:
-            - ${bref:layer.php-74}
+        runtime: php-81
         events:
             - httpApi: 'GET /articles/{id}'
 ```
diff --git a/docs/function/local-development.md b/docs/function/local-development.md
index 0b8105e22..025046e0c 100644
--- a/docs/function/local-development.md
+++ b/docs/function/local-development.md
@@ -11,9 +11,15 @@ next:
 
 It is possible to run **PHP functions** locally.
 
+> **Warning:**
 > To run **web apps** locally, see [Local development for HTTP applications](/docs/web-apps/local-development.md) instead.
 
-The `vendor/bin/bref local` command invokes your [PHP functions](/docs/runtimes/function.md) locally. You can provide an event if your function expects one.
+## With Serverless Framework
+
+The `serverless bref:local` command invokes your [PHP functions](/docs/runtimes/function.md) locally, using PHP installed on your machine. You can provide an event if your function expects one.
+
+> **Note:**
+> The `serverless bref:local` command is a simpler alternative to the native `serverless invoke local` command, which tries to run PHP using Docker with very little success. Use `bref:local` instead of `invoke local`.
 
 For example, given this function:
 
@@ -29,55 +35,75 @@ return function (array $event) {
 functions:
     hello:
         handler: my-function.php
-        layers:
-            - ${bref:layer.php-74}
+        runtime: php-81
 ```
 
 You can invoke it with or without event data:
 
 ```bash
-$ vendor/bin/bref local hello
+$ serverless bref:local -f hello
 Hello world
 
 # With JSON event data
-$ vendor/bin/bref local hello '{"name": "Jane"}'
+$ serverless bref:local -f hello --data '{"name": "Jane"}'
 Hello Jane
 
 # With JSON in a file
-$ vendor/bin/bref local hello --file=event.json
+$ serverless bref:local -f hello --path=event.json
 Hello Jane
 ```
 
 > **Note:** On Windows PowerShell, you must escape the "double quote" char if you write JSON directly in the CLI. Example: 
 > ```bash
-> $ vendor/bin/bref local hello '{\"name\": \"Bill\"}'
+> $ serverless bref:local -f hello --data '{\"name\": \"Bill\"}'
 > ```
 
-The `bref local` command runs using the local PHP installation. If you prefer to run commands using the same environment as Lambda, you can use Docker.
+The `serverless bref:local` command runs using the local PHP installation. If you prefer to use Docker, check out the "Without Serverless Framework" section below.
 
-Here is an example, feel free to adjust it to fit your needs:
+## API Gateway local development
 
-```bash
-docker run --rm -it --entrypoint= -v $(PWD):/var/task:ro bref/php-74 vendor/bin/bref local hello
-```
+If you build HTTP applications with [API Gateway HTTP events](handlers.md#api-gateway-http-events), `serverless bref:local` is a bit unpractical because you need to manually craft HTTP events in JSON.
 
-If you do not use `serverless.yml` but something else like SAM/CDK/CloudFormation/Terraform, use the `--handler` parameter instead:
+Instead, you can use the [`bref/dev-server`](https://github.com/brefphp/dev-server) package to emulate API Gateway locally.
+
+## Without Serverless Framework
+
+If you do not use `serverless.yml` but something else, like SAM/AWS CDK/Terraform, use the `vendor/bin/bref-local` command instead:
 
 ```bash
-$ vendor/bin/bref local --handler=my-function.php
+$ vendor/bin/bref-local <handler> <event-data>
+
+# For example
+$ vendor/bin/bref-local my-function.php
 Hello world
 
 # With JSON event data
-$ vendor/bin/bref local '{"name": "Jane"}' --handler=my-function.php
+$ vendor/bin/bref-local my-function.php '{"name": "Jane"}'
 Hello Jane
+```
 
-# With JSON in a file
-$ vendor/bin/bref local --handler=my-function.php --file=event.json
-Hello Jane
+If you want to run your function in Docker:
+
+```bash
+$ docker run --rm -it --entrypoint= -v $(PWD):/var/task:ro bref/php-81:2 vendor/bin/bref-local my-function.php
+
+# You can also use the `dev` images for a simpler command (and Xdebug and Blackfire in the image):
+$ docker run --rm -it -v $(PWD):/var/task:ro bref/php-81-fpm-dev:2 vendor/bin/bref-local my-function.php
 ```
 
-## API Gateway local development
+You can also use Docker Compose:
 
-If you build HTTP applications with [API Gateway HTTP events](handlers.md#api-gateway-http-events), `bref local` is a bit unpractical.
+```yaml
+version: "3.5"
+services:
+    app:
+        image: bref/php-81-fpm-dev:2
+        volumes:
+            - .:/var/task
+```
+
+Then run functions:
 
-You can use the [`bref/dev-server`](https://github.com/brefphp/dev-server) package to emulate API Gateway locally.
+```bash
+$ docker-compose run app vendor/bin/bref-local my-function.php
+```
diff --git a/docs/installation.md b/docs/installation.md
index f6a58e170..8ea2607bd 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -50,7 +50,7 @@ composer require bref/bref
 
 > Make sure that the version of Bref that was installed is 1.0 or greater.
 
-> To run the latest version of Bref you must have PHP 7.3 or greater! If you are using PHP 7.2 or less, an outdated version of Bref will be installed instead.
+> To run the latest version of Bref you must have PHP 8.0 or greater! If you are using PHP 7.4 or less, an outdated version of Bref will be installed instead.
 
 The `bref` command line tool can now be used by running `vendor/bin/bref` in your project.
 
diff --git a/docs/runtimes/README.md b/docs/runtimes/README.md
index 9f04589f3..ef287e2fe 100644
--- a/docs/runtimes/README.md
+++ b/docs/runtimes/README.md
@@ -12,7 +12,7 @@ next:
 
 There is no built-in support for PHP on AWS Lambda. Instead, we can use 3rd party runtimes via [AWS Lambda *layers*](https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html).
 
-Bref provides the runtimes (or "layers") to run PHP on Lambda.
+**Bref provides open-source runtimes to run PHP on Lambda** (distributed as AWS Lambda layers).
 
 ## Bref runtimes
 
@@ -27,7 +27,7 @@ These runtimes are available as AWS Lambda layers that you can use (explained be
 
 ### Web apps
 
-Name: `php-82-fpm`, `php-81-fpm`, `php-80-fpm` and `php-74-fpm`.
+Name: `php-82-fpm`, `php-81-fpm`, and `php-80-fpm`.
 
 This runtime uses PHP-FPM to run **web applications** on AWS Lambda.
 
@@ -37,11 +37,11 @@ It's **the easiest to start with**: it works like traditional PHP hosting and is
 
 ### Event-driven functions
 
-Name: `php-82`, `php-81`, `php-80` and `php-74`.
+Name: `php-82`, `php-81`, and `php-80`.
 
 AWS Lambda was initially created to run _functions_ (yes, functions of code) in the cloud.
 
-The Bref function runtime lets you create Lambda functions in PHP like with any other language.
+The Bref "function" runtime lets you create Lambda functions in PHP like with any other language.
 
 This runtime works great to create **event-driven micro-services**.
 
@@ -49,9 +49,11 @@ _Note: if you are getting started, we highly recommend using the FPM runtime ins
 
 [Get started with the Function runtime in "Bref for event-driven functions"](/docs/runtimes/function.md).
 
-### Console: `console`
+### Console
 
-This runtime lets you run console commands on Lambda.
+Name: `php-82-console`, `php-81-console`, and `php-80-console`.
+
+This runtime lets you run CLI console commands on Lambda.
 
 For example, we can run the [Symfony Console](https://symfony.com/doc/master/components/console.html) or [Laravel Artisan](https://laravel.com/docs/artisan).
 
@@ -59,7 +61,94 @@ For example, we can run the [Symfony Console](https://symfony.com/doc/master/com
 
 ## Usage
 
-To use a runtime, import the corresponding layer in `serverless.yml`:
+To use a runtime, set it on each function in `serverless.yml`:
+
+```yaml
+service: app
+provider:
+    name: aws
+plugins:
+    - ./vendor/bref/bref
+functions:
+    hello:
+        # ...
+        runtime: php-81
+        # or:
+        runtime: php-81-fpm
+        # or:
+        runtime: php-81-console
+```
+
+Bref currently provides runtimes for PHP 8.0, 8.1 and 8.2:
+
+- `php-82`
+- `php-81`
+- `php-80`
+- `php-82-fpm`
+- `php-81-fpm`
+- `php-80-fpm`
+- `php-82-console`
+- `php-81-console`
+- `php-80-console`
+
+> `php-80` means PHP 8.0.\*. It is not possible to require a specific "patch" version. The latest Bref versions always aim to support the latest PHP versions, so upgrade frequently to keep PHP up to date.
+
+### ARM runtimes
+
+It is possible to run AWS Lambda functions on [ARM-based AWS Graviton processors](https://aws.amazon.com/blogs/aws/aws-lambda-functions-powered-by-aws-graviton2-processor-run-your-functions-on-arm-and-get-up-to-34-better-price-performance/). This is usually considered a way to reduce costs and improve performance.
+
+You can deploy to ARM by using the `arm64` architecture:
+
+```diff
+functions:
+    api:
+        handler: public/index.php
+        runtime: php-81-fpm
++       architecture: arm64
+```
+
+The Bref plugin will detect that change and automatically use the Bref ARM Lambda layers.
+
+### The Bref plugin for serverless.yml
+
+Make sure to always include the Bref plugin in your `serverless.yml` config:
+
+```yaml
+plugins:
+    - ./vendor/bref/bref
+```
+
+This plugin is what makes `runtime: php-81` work (as well as other utilities). It is explained in more details in the section below.
+
+### AWS Lambda layers
+
+The `runtime: php-xxx` runtimes we use in `serverless.yml` are not _real_ AWS Lambda runtimes. Indeed, PHP is not supported natively on AWS Lambda.
+
+What the Bref plugin for `serverless.yml` (the one we include with `./vendor/bref/bref`) does is it automatically turns this:
+
+```yaml
+functions:
+    hello:
+        # ...
+        runtime: php-81
+```
+
+into this:
+
+```yaml
+functions:
+    hello:
+        # ...
+        runtime: provided.al2
+        layers:
+            - 'arn:aws:lambda:us-east-1:534081306603:layer:php-81:21'
+```
+
+☝️ `provided.al2` [is the generic Linux environment for custom runtimes](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html#runtimes-custom-use), and the `layers` config points to Bref's AWS Lambda layers.
+
+Thanks to the Bref plugin, our `serverless.yml` is simpler. It also automatically adapts to the AWS region in use, and automatically points to the correct layer version. You will learn more about "layers" below in this page.
+
+If you want to reference AWS Lambda layers directly (instead of using the simpler `runtime: php-81` syntax), the Bref plugin also provides simple `serverless.yml` variables. These were the default in Bref v1.x, so you may find this older syntax on tutorials and blog posts:
 
 ```yaml
 service: app
@@ -70,30 +159,26 @@ plugins:
     - ./vendor/bref/bref
 functions:
     hello:
-        ...
+        # ...
         layers:
             - ${bref:layer.php-80}
             # or:
             - ${bref:layer.php-80-fpm}
 ```
 
-The `${...}` notation is the [syntax to use variables](https://serverless.com/framework/docs/providers/aws/guide/variables/) in `serverless.yml`. Bref provides a serverless plugin ("`./vendor/bref/bref`") that provides those variables:
+The `${...}` notation is the [syntax to use variables](https://serverless.com/framework/docs/providers/aws/guide/variables/) in `serverless.yml`. The Bref plugin provides the following variables:
 
 - `${bref:layer.php-82}`
 - `${bref:layer.php-81}`
 - `${bref:layer.php-80}`
-- `${bref:layer.php-74}`
-- `${bref:layer.php-73}`
 - `${bref:layer.php-82-fpm}`
 - `${bref:layer.php-81-fpm}`
 - `${bref:layer.php-80-fpm}`
-- `${bref:layer.php-74-fpm}`
-- `${bref:layer.php-73-fpm}`
 - `${bref:layer.console}`
 
-Bref currently provides runtimes for PHP 7.3, 7.4, 8.0, 8.1 and 8.2.
+Bref ARM layers are the same as the x86 layers, but with the `arm-` prefix in their name, for example `${bref:layer.arm-php-82}`. The only exception is `${bref:layer.console}` (this is the same layer for both x86 and ARM).
 
-> `php-80` means PHP 8.0.\*. It is not possible to require a specific "patch" version.
+> **Note**: to be clear, it is easier and recommended to use the `runtime: php-xxx` option instead of setting `layers` directly.
 
 ## Lambda layers in details
 
@@ -103,18 +188,18 @@ Bref currently provides runtimes for PHP 7.3, 7.4, 8.0, 8.1 and 8.2.
 >
 > ▶ [**Get started with web apps**](/docs/runtimes/http.md).
 
-Bref runtimes are [AWS Lambda layers](https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html). While Bref provides a Serverless plugin to simplify how to use them, you can use the layers directly.
+Bref runtimes are distributed as [AWS Lambda layers](https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html). While Bref provides a Serverless plugin to simplify how to use them, you can use the layers directly.
 
 The layer names follow this pattern:
 
 ```
-arn:aws:lambda:<region>:209497400698:layer:<layer-name>:<layer-version>
+arn:aws:lambda:<region>:534081306603:layer:<layer-name>:<layer-version>
 
-For example:
-arn:aws:lambda:us-east-1:209497400698:layer:php-80:21
+# For example:
+arn:aws:lambda:us-east-1:534081306603:layer:php-80:21
 ```
 
-You can use layers via their full ARN, or example in `serverless.yml`:
+You can use layers via their full ARN, for example in `serverless.yml`:
 
 ```yaml
 service: app
@@ -125,7 +210,7 @@ functions:
     hello:
         ...
         layers:
-            - 'arn:aws:lambda:us-east-1:209497400698:layer:php-80:21'
+            - 'arn:aws:lambda:us-east-1:534081306603:layer:php-80:21'
 ```
 
 Or if you are using [SAM's `template.yaml`](https://aws.amazon.com/serverless/sam/):
@@ -140,20 +225,32 @@ Resources:
             ...
             Runtime: provided.al2
             Layers:
-                - 'arn:aws:lambda:us-east-1:209497400698:layer:php-80:21'
+                - 'arn:aws:lambda:us-east-1:534081306603:layer:php-80:21'
 ```
 
 Bref layers work with AWS Lambda regardless of the tool you use to deploy your application: Serverless, SAM, CloudFormation, Terraform, AWS CDK, etc.
 
 > Remember: the layer ARN contains a region. **You need to use the same region as the rest of your application** else Lambda will not find the layer.
 
+### Layers NPM package
+
+You can use [the `@bref.sh/layers.js` NPM package](https://github.com/brefphp/layers.js) to get up-to-date layer ARNs in Node applications, for example with the AWS CDK.
+
 ### Layer version (`<layer-version>`)
 
-The latest of runtime versions can be found at [runtimes.bref.sh](https://runtimes.bref.sh/) and is shown below:
+The latest of runtime versions can be found at [**runtimes.bref.sh**](https://runtimes.bref.sh/).
+
+Here are the latest versions:
 
 <iframe src="https://runtimes.bref.sh/embedded" class="w-full h-96"></iframe>
 
-**Watch out:** if you use the layer ARN directly instead of the `${bref:layer.php-80}` variables (which only work in `serverless.yml`), you may need to update the ARN (the `<version>` part) when you update Bref. Follow the Bref release notes closely.
+You can also find the appropriate ARN/version for your current Bref version by running:
+
+```bash
+serverless bref:layers
+```
+
+**Watch out:** if you use the layer ARN directly, you may need to update the ARN (the `<version>` part) when you update Bref. Follow the Bref release notes closely.
 
 ### Bref ping
 
diff --git a/docs/runtimes/console.md b/docs/runtimes/console.md
index de3be92c0..f0118b235 100644
--- a/docs/runtimes/console.md
+++ b/docs/runtimes/console.md
@@ -16,61 +16,82 @@ This can be used to run PHP scripts, the [Symfony Console](https://symfony.com/d
 
 ## Configuration
 
-The lambda function used for running console applications must use two Lambda layers:
-
-- the base PHP layer that provides the `php` binary,
-- the `console` layer that overrides the base runtime to execute our console commands.
-
-Below is a minimal `serverless.yml`.
+The lambda function used for running console applications must use the `php-xx-console` runtime. Here is an example `serverless.yml`:
 
 ```yaml
 service: app
 provider:
     name: aws
-    runtime: provided.al2
 plugins:
     - ./vendor/bref/bref
 functions:
     hello:
         handler: bin/console # or 'artisan' for Laravel
-        layers:
-            - ${bref:layer.php-74} # PHP runtime
-            - ${bref:layer.console} # Console layer
+        runtime: php-81-console
 ```
 
+Behind the scenes, the `php-xx-console` runtime will deploy a Lambda function configured to use Bref's `php-81` AWS Lambda layer plus Bref's `console` layer (read more about these in the [runtimes documentation](./README.md)).
+
 ## Usage
 
-To run a console command on AWS Lambda, run `bref cli` on your computer:
+To run a console command on AWS Lambda, run `serverless bref:cli` on your computer:
 
 ```bash
-vendor/bin/bref cli <function-name> -- <command>
+serverless bref:cli --args="<command to run in lambda>"
 ```
 
-`<function-name>` is the name of the function that was deployed on AWS. In our example above that would be `hello-dev` because Serverless adds the stage (by default `dev`) as a suffix.
-
-Pass your command, arguments and options by putting them after `--`. The `--` delimiter separates between options for the `bref cli` command (before `--`) and your command (after `--`).
-
-```bash
-vendor/bin/bref cli hello-dev <bref options> -- <your command, your options>
-```
+The `bref:cli` command will automatically detect which function (in `serverless.yml`) uses the `console` runtime and will run the command on that function.
 
-For example:
+Pass your command arguments and options in the `--args` flag (shortcut: `-a`). Remember to escape quotes properly. Some examples:
 
 ```bash
 # Runs the CLI application without arguments and displays the help
-$ vendor/bin/bref cli hello-dev
+$ serverless bref:cli
 # ...
 
-$ vendor/bin/bref cli hello-dev -- doctrine:migrations:migrate
+$ serverless bref:cli --args="doctrine:migrations:migrate"
 Your database will be migrated.
 To execute the SQL queries run the command with the `--force` option.
 
-$ vendor/bin/bref cli hello-dev -- doctrine:migrations:migrate --force
+$ serverless bref:cli -a "doctrine:migrations:migrate --force"
 Your database has been migrated.
 
-# Use environment variables to configure your AWS credentials
-$ AWS_DEFAULT_REGION=eu-central-1 AWS_ACCESS_KEY_ID=foo AWS_SECRET_ACCESS_KEY=bar vendor/bin/bref cli my-function
+$ serverless bref:cli --stage=prod -a "db:dump --file='/tmp/dump.sql' --verbose"
 # ...
+
+# You can use environment variables to configure AWS credentials (e.g. in CI)
+$ AWS_ACCESS_KEY_ID=foo AWS_SECRET_ACCESS_KEY=bar serverless bref:cli
+# ...
+```
+
+### Usage without Serverless Framework
+
+If you do not use `serverless.yml` but something else, like SAM/AWS CDK/Terraform, you can invoke your console function via the AWS CLI. For example:
+
+```bash
+aws lambda invoke \
+    --function-name <console function name> \
+    --region <region> \
+    --cli-binary-format raw-in-base64-out \
+    --payload "<command arguments and options>" \
+    <file to store the output>.json
+
+# For example:
+aws lambda invoke \
+    --function-name myapp-dev-myfunction \
+    --region us-east-1 \
+    --cli-binary-format raw-in-base64-out \
+    --payload "doctrine:migrations:migrate --force" \
+    response.json
+
+# To extract the command output from the response.json file using jq
+# https://stedolan.github.io/jq/
+aws lambda invoke \
+    --function-name myapp-dev-myfunction \
+    --region us-east-1 \
+    --cli-binary-format raw-in-base64-out \
+    --payload "doctrine:migrations:migrate --force" \
+    response.json && jq -r .output response.json
 ```
 
 The [Bref Dashboard](https://dashboard.bref.sh/?ref=bref) also provides a convenient way to run commands via a terminal:
diff --git a/docs/runtimes/function.md b/docs/runtimes/function.md
index 864f8a4a1..627a74d34 100644
--- a/docs/runtimes/function.md
+++ b/docs/runtimes/function.md
@@ -88,11 +88,10 @@ plugins:
 functions:
     hello:
         handler: my-function.php
-        layers:
-            - ${bref:layer.php-74}
+        runtime: php-81
 ```
 
-The runtime (aka layer) to use is `php-XX`. To learn more check out [the runtimes documentation](/docs/runtimes/README.md).
+The runtime to use is `php-XX`. To learn more check out [the runtimes documentation](/docs/runtimes/README.md).
 
 ## Invocation
 
diff --git a/docs/runtimes/http.md b/docs/runtimes/http.md
index a28138a01..0258751cf 100644
--- a/docs/runtimes/http.md
+++ b/docs/runtimes/http.md
@@ -34,8 +34,7 @@ plugins:
 functions:
     app:
         handler: index.php
-        layers:
-            - ${bref:layer.php-82-fpm}
+        runtime: php-81-fpm
         events:
             - httpApi: '*'
 ```
@@ -56,13 +55,12 @@ functions:
 
 ## Runtime
 
-For web apps, the runtime (aka layer) to use is the **FPM** runtime (`php-82-fpm`):
+For web apps, the runtime to use is the **FPM** runtime (`php-81-fpm`):
 
 ```yaml
 functions:
     app:
-        layers:
-            - ${bref:layer.php-82-fpm}
+        runtime: php-81-fpm
 ```
 
 To learn more check out [the runtimes documentation](/docs/runtimes/README.md).
diff --git a/docs/upgrading/v2.md b/docs/upgrading/v2.md
new file mode 100644
index 000000000..b0c33f976
--- /dev/null
+++ b/docs/upgrading/v2.md
@@ -0,0 +1,145 @@
+---
+title: Upgrading to Bref 2.0
+current_menu: upgrading
+introduction: Upgrading guide to go from Bref 1.x to Bref 2.0.
+---
+
+## Updating dependencies
+
+### PHP 8 required
+
+Bref 2.0 now requires PHP 8.0 or greater.
+
+### Composer Dependencies
+
+You should update the `bref/bref` dependency in your application's composer.json file:
+
+```diff
+-    "bref/bref": "^1.0",
++    "bref/bref": "^2.0@beta",
+```
+
+Then run `composer update bref/bref --with-all-dependencies`.
+
+### Serverless Framework
+
+If you are using Bref with [Serverless Framework](https://www.serverless.com/framework) (which is the default), Bref 2.0 requires Serverless Framework v3. Serverless Framework 2.x is no longer supported.
+
+To check your Serverless Framework version, run:
+
+```bash
+serverless --version
+```
+
+If you need to upgrade, [read the Serverless Framework documentation](https://www.serverless.com/framework/docs/getting-started#upgrade) (short version: run `npm install -g serverless`).
+
+## PHP runtimes
+
+There is a new (simpler) syntax to use Bref's PHP runtimes in `serverless.yml`:
+
+```yaml
+functions:
+    hello:
+        # ...
+        runtime: php-81
+        
+        # instead of:
+        runtime: provided.al2
+        layers:
+          - ${bref:layer.php-81}
+```
+
+The [bref.sh](https://bref.sh) documentation now uses the simpler `runtime: php-81` syntax, but `${bref:layer.php-xxx}` variables still work! These variables are not deprecated. There are no breaking changes here.
+
+## Bref CLI
+
+The following commands of `vendor/bin/bref` have changed:
+
+- `vendor/bin/bref cli` is replaced by the simpler `serverless bref:cli`.
+
+  Read [the Console documentation](../runtimes/console.md#usage) to learn more. You will also find alternatives if you don't use the `serverless` CLI.
+
+- `vendor/bin/bref local` is replaced by the simpler `serverless bref:local`.
+
+  Read [the Local Development documentation](../function/local-development.md) to learn more. You will also find alternatives if you don't use the `serverless` CLI.
+
+- `vendor/bin/bref layers` is replaced by the simpler `serverless layers`.
+
+  Layer versions are also available at [runtimes.bref.sh](https://runtimes.bref.sh/) if you don't use the `serverless` CLI.
+
+These changes allowed us to simplify the commands (automatically use the AWS region, credentials and stage from the `serverless` CLI). It also allowed us to remove the biggest `bref/bref` Composer dependencies and make the package much lighter.
+
+## Development Docker images
+
+The development Docker images have been simplified. You will need to update your `serverless-compose.yml`.
+
+Before (Bref v1):
+
+```yaml
+services:
+
+    web:
+        image: bref/fpm-dev-gateway
+        ports:
+            - '8000:80'
+        volumes:
+            - .:/var/task
+        depends_on:
+            - php
+        environment:
+            HANDLER: public/index.php
+            DOCUMENT_ROOT: public
+
+    app:
+        image: bref/php-80-fpm-dev
+        volumes:
+            - .:/var/task
+
+    console:
+        image: bref/php-80
+        volumes:
+            - .:/var/task
+        entrypoint: php
+```
+
+After (Bref v2):
+
+```yaml
+services:
+    app:
+        image: bref/php-80-fpm-dev:2
+        ports: [ '8000:8000' ]
+        volumes:
+            - .:/var/task
+        environment:
+            HANDLER: public/index.php
+            DOCUMENT_ROOT: public
+```
+
+The `bref/php-XX-fpm-dev` images can now run HTTP applications, console commands as well as event-driven functions too. Read more in [web app local development](../web-apps/local-development.md).
+
+The `bref/fpm-dev-gateway` image is no longer needed, and code running in `bref/php-XX-fpm-dev` now runs in an environment even closer to production.
+
+> Note: In order to be future-proof, we recommend to set the Bref major version in the Docker images. For example, with Bref 2, use `bref/php-80-fpm-dev:2` instead of just `bref/php-80-fpm-dev`.
+
+## Smaller breaking changes that might impact you
+
+The changes below should not impact the majority of users. However, if you are using any of these features, you might need to update your code.
+
+### Removed `separateVendor: true`
+
+The `separateVendor` option (in `serverless.yml`) has been removed.
+
+It allowed to deploy the `vendor/` directory separately from the app to reduce the size of the deployment package. It was very buggy (contributed a long time ago and not maintained by anyone) and added a lot of technical debt.
+
+Instead, when hitting the 250MB limit, you are encouraged to [deploy via Docker images](https://bref.sh/docs/web-apps/docker.html) instead (which have a limit of 10GB).
+
+### Removed `Bref\Lambda\SimpleLambdaClient`
+
+The `Bref\Lambda\SimpleLambdaClient` class has been removed. It was a simple wrapper around the AWS SDK that was used internally in Bref.
+
+If you were using this class in your application, you can use the AWS SDK directly, or the simpler and lighter [Async AWS package](https://async-aws.com/clients/lambda.html).
+
+### Removed `Bref\Websocket\SimpleWebsocketClient`
+
+The `Bref\Websocket\SimpleWebsocketClient` class has been extracted to a separate package: [bref/api-gateway-websocket-client](https://github.com/brefphp/api-gateway-websocket-client).
diff --git a/docs/web-apps/cron.md b/docs/web-apps/cron.md
index 587750b44..bdbaf2cbe 100644
--- a/docs/web-apps/cron.md
+++ b/docs/web-apps/cron.md
@@ -16,9 +16,7 @@ AWS Lambda lets us run [console commands](/docs/runtimes/console.md) as cron tas
 functions:
     cron:
         handler: bin/console # or 'artisan' for Laravel
-        layers:
-            - ${bref:layer.php-74} # PHP runtime
-            - ${bref:layer.console} # Console layer
+        runtime: php-81-console
         events:
             - schedule:
                   rate: rate(1 hour)
diff --git a/docs/web-apps/docker.md b/docs/web-apps/docker.md
index 03950fcda..c51b2e222 100644
--- a/docs/web-apps/docker.md
+++ b/docs/web-apps/docker.md
@@ -6,12 +6,13 @@ previous:
     title: Local development for web apps
 ---
 
-AWS Lambda support running a Docker Container as a handler
-for your function. This alternative **is a last resort** when:
+AWS Lambda supports running a Docker image, instead of running your application in the default Linux environment. We recommend Docker **as a last resort**, as it is less practical and usually comes with slightly worse cold starts. Yes, Docker is great and probably sounds familiar, but is often not worth it on Lambda.
 
-- Your Lambda Function is larger than 250MB when unzipped
-- You need more than 5 extensions
-- You need resources locally installed (e.g. mysqldump)
+You should consider deploying using Docker when:
+
+- Your Lambda Function is [larger than 250MB when unzipped](../environment/storage.md)
+- You reached the limit of 5 Lambda layers (e.g. for extra PHP extensions)
+- You need resources installed locally (e.g. mysqldump)
 
 > Note: this documentation page assumes that you have read about [web apps on Lambda](../runtimes/http.md) first.
 
@@ -74,7 +75,7 @@ functions:
             - httpApi: '*'
 ```
 
-Instead of having a `handler` and a `layer`, we'll declare an
+Instead of having a `handler` and a `runtime`, we'll declare an
 `image`. In the `provider` block, we'll declare Docker images
 that we want to build and deploy.
 
diff --git a/docs/web-apps/local-development.md b/docs/web-apps/local-development.md
index 7b7b1a5ea..273d6a9f4 100644
--- a/docs/web-apps/local-development.md
+++ b/docs/web-apps/local-development.md
@@ -32,44 +32,39 @@ In order to run the application locally in an environment closer to production,
 version: "3.5"
 
 services:
-    web:
-        image: bref/fpm-dev-gateway
-        ports:
-            - '8000:80'
+    app:
+        image: bref/php-81-fpm-dev:2
+        ports: [ '8000:8000' ]
         volumes:
             - .:/var/task
-        depends_on:
-            - php
         environment:
-            HANDLER: index.php
-    php:
-        image: bref/php-74-fpm-dev
-        volumes:
-            - .:/var/task:ro
+            HANDLER: public/index.php
 ```
 
 After running `docker-compose up`, the application will be available at [http://localhost:8000/](http://localhost:8000/).
 
 The `HANDLER` environment variable lets you define which PHP file will be handling all HTTP requests. This should be the same handler that you have defined in `serverless.yml` for your HTTP function.
 
-> Currently the Docker image support only one PHP handler. If you have multiple HTTP functions in `serverless.yml`, you can duplicate the service in `docker-compose.yml` to have one container per lambda function.
+> Currently, the Docker image support only one PHP handler. If you have multiple HTTP functions in `serverless.yml`, you can duplicate the service in `docker-compose.yml` to have one container per lambda function.
 
 ### Read-only filesystem
 
-The code will be mounted as read-only in `/var/task`, just like in Lambda. However when developing locally, it is common to regenerate cache files on the fly (for example Symfony or Laravel cache). You have 2 options:
+The code will be mounted in `/var/task`, just like in Lambda. But in Lambda, `/var/task` is read-only.
+
+When developing locally, it is common to regenerate cache files on the fly (for example Symfony or Laravel cache). You have 2 options:
 
 - mount the whole codebase as writable:
 
     ```yaml
-        volumes:
-            - .:/var/task
+            volumes:
+                - .:/var/task
     ```
 - mount a specific cache directory as writable (better):
 
     ```yaml
-        volumes:
-            - .:/var/task:ro
-            - ./cache:/var/task/cache
+            volumes:
+                - .:/var/task:ro
+                - ./storage:/var/task/storage
     ```
 
 ### Assets
@@ -80,21 +75,14 @@ If you want to serve assets locally, you can define a `DOCUMENT_ROOT` environmen
 version: "3.5"
 
 services:
-    web:
-        image: bref/fpm-dev-gateway
-        ports:
-            - '8000:80'
+    app:
+        image: bref/php-81-fpm-dev:2
+        ports: [ '8000:8000' ]
         volumes:
             - .:/var/task
-        links:
-            - php
         environment:
             HANDLER: public/index.php
             DOCUMENT_ROOT: public
-    php:
-        image: bref/php-74-fpm-dev
-        volumes:
-            - .:/var/task:ro
 ```
 
 In the example above, a `public/assets/style.css` file will be accessible at `http://localhost:8000/assets/style.css`.
@@ -103,7 +91,7 @@ In the example above, a `public/assets/style.css` file will be accessible at `ht
 
 ### Xdebug
 
-The docker container `bref/php-<version>-fpm-dev` comes with Xdebug pre-installed.
+The development container (`bref/php-<version>-fpm-dev`) comes with Xdebug pre-installed.
 
 To enable it, create a `php/conf.dev.d/php.ini` file in your project containing:
 
@@ -115,13 +103,7 @@ Now start the debug session by issuing a request to your application [in the bro
 
 #### Xdebug and MacOS
 
-Docker for Mac uses a virtual machine for running docker. That means you need to use a special host name that is mapped to the host machine's IP address.
-
-The host name to use depends on your version of Docker for Mac:
-
-- v18.03.0-ce-mac59+ uses `host.docker.internal`
-- v17.12.0-ce-mac46+ uses `docker.for.mac.host.internal`
-- v17.06.0+ uses `docker.for.mac.localhost`
+Docker for Mac uses a virtual machine for running docker. That means you need to use a special host name (`host.docker.internal`) that is mapped to the host machine's IP address.
 
 Edit the `php/conf.dev.d/php.ini` file:
 
@@ -136,15 +118,15 @@ xdebug.remote_host = 'host.docker.internal'
 
 ### Blackfire
 
-The development FPM container comes with the blackfire extension. When using docker compose you can add following service configuration for the blackfire agent:
+The development container (`bref/php-<version>-fpm-dev`) comes with the [blackfire](https://www.blackfire.io/) extension. When using docker compose, you can add the following service for the blackfire agent:
 
 ```yaml
 services:
-  blackfire:
-    image: blackfire/blackfire
-    environment:
-      BLACKFIRE_SERVER_ID: server-id
-      BLACKFIRE_SERVER_TOKEN: server-token
+    blackfire:
+        image: blackfire/blackfire
+        environment:
+            BLACKFIRE_SERVER_ID: server-id
+            BLACKFIRE_SERVER_TOKEN: server-token
 ```
 
 In order to enable the probe you can create a folder `php/conf.dev.d` in your project and include an ini file enabling blackfire:
@@ -162,21 +144,27 @@ Console applications can be tested just like before: by running the command in y
 
 For example with Symfony you can run `bin/console <your-command>` , or with Laravel run `php artisan <your-command>`.
 
-If you want to run your console in an environment close to production, you can use the Bref Docker images. Here is an example of a `docker-compose.yml` file:
+If you want to run your console in an environment close to production, you can use the Bref Docker dev images documented above. For example, if you have a `docker-compose.yml` file like this:
 
 ```yaml
 version: "3.5"
 
 services:
-    console:
-        image: bref/php-74
+    app:
+        image: bref/php-81-fpm-dev:2
+        ports: [ '8000:8000' ]
         volumes:
-            - .:/var/task:ro
-        entrypoint: php
+            - .:/var/task
+        environment:
+            HANDLER: public/index.php
 ```
 
-Then commands can be run via:
+Then CLI commands can be run in Docker via:
 
 ```bash
-docker-compose run console bin/console <your-command>
+# Symfony (bin/console)
+docker-compose run app php bin/console <your-command>
+
+# Laravel (artisan)
+docker-compose run app php artisan <your-command>
 ```
diff --git a/index.js b/index.js
index cd198f53a..f25ea816e 100644
--- a/index.js
+++ b/index.js
@@ -1,5 +1,12 @@
 'use strict';
 
+const {listLayers} = require('./plugin/layers');
+const {runConsole} = require('./plugin/run-console');
+const {runLocal} = require('./plugin/local');
+const {warnIfUsingSecretsWithoutTheBrefDependency} = require('./plugin/secrets');
+const fs = require('fs');
+const path = require('path');
+
 /**
  * This file declares a plugin for the Serverless framework.
  *
@@ -9,22 +16,38 @@
 class ServerlessPlugin {
     constructor(serverless, options, utils) {
         this.serverless = serverless;
-        this.options = options;
-        this.utils = utils;
         this.provider = this.serverless.getProvider('aws');
 
-        this.fs = require('fs');
-        this.path = require('path');
-        const filename = this.path.resolve(__dirname, 'layers.json');
-        const layers = JSON.parse(this.fs.readFileSync(filename));
+        if (!utils) {
+            throw new serverless.classes.Error('Bref requires Serverless Framework v3, but an older v2 version is running.\nPlease upgrade to Serverless Framework v3.');
+        }
+        this.utils = utils;
+
+        // Automatically enable faster deployments (unless a value is already set)
+        // https://www.serverless.com/framework/docs/providers/aws/guide/deploying#deployment-method
+        if (! serverless.service.provider.deploymentMethod) {
+            serverless.service.provider.deploymentMethod = 'direct';
+        }
+
+        const filename = path.resolve(__dirname, 'layers.json');
+        this.layers = JSON.parse(fs.readFileSync(filename).toString());
+
+        this.runtimes = Object.keys(this.layers)
+            .filter(name => !name.startsWith('arm-'));
+        // Console runtimes must have a PHP version provided
+        this.runtimes = this.runtimes.filter(name => name !== 'console');
+        this.runtimes.push('php-80-console', 'php-81-console', 'php-82-console');
 
         this.checkCompatibleRuntime();
 
+        serverless.configSchemaHandler.schema.definitions.awsLambdaRuntime.enum.push(...this.runtimes);
+
         // Declare `${bref:xxx}` variables
         // See https://www.serverless.com/framework/docs/guides/plugins/custom-variables
+        // noinspection JSUnusedGlobalSymbols
         this.configurationVariablesSources = {
             bref: {
-                async resolve({address, params, resolveConfigurationProperty, options}) {
+                resolve: async ({address, resolveConfigurationProperty, options}) => {
                     // `address` and `params` reflect values configured with a variable: ${bref(param1, param2):address}
 
                     // `options` is CLI options
@@ -36,316 +59,154 @@ class ServerlessPlugin {
                         throw new serverless.classes.Error(`Unknown Bref variable \${bref:${address}}, the only supported syntax right now is \${bref:layer.XXX}`);
                     }
 
-                    const layerName = address.substr('layer.'.length);
-                    if (! (layerName in layers)) {
-                        throw new serverless.classes.Error(`Unknown Bref layer named "${layerName}".\nIs that a typo? Check out https://bref.sh/docs/runtimes/ to see the correct name of Bref layers.`);
-                    }
-                    if (! (region in layers[layerName])) {
-                        throw new serverless.classes.Error(`There is no Bref layer named "${layerName}" in region "${region}".\nThat region may not be supported yet. Check out https://runtimes.bref.sh to see the list of supported regions.\nOpen an issue to ask for that region to be supported: https://github.com/brefphp/bref/issues`);
-                    }
-                    const version = layers[layerName][region];
+                    const layerName = address.substring('layer.'.length);
                     return {
-                        value: `arn:aws:lambda:${region}:209497400698:layer:${layerName}:${version}`,
+                        value: this.getLayerArn(layerName, region),
                     }
                 }
             }
         };
 
-        // If we are on Serverless Framework v2, set up the legacy variable resolver
-        if (!this.utils) {
-            // This is the legacy way of declaring `${bref:xxx}` variables. This has been deprecated in 20210326.
-            // Override the variable resolver to declare our own variables
-            const delegate = this.serverless.variables
-                .getValueFromSource.bind(this.serverless.variables);
-            this.serverless.variables.getValueFromSource = (variableString) => {
-                if (variableString.startsWith('bref:layer.')) {
-                    const region = this.provider.getRegion();
-                    const layerName = variableString.substr('bref:layer.'.length);
-                    if (!(layerName in layers)) {
-                        throw new serverless.classes.Error(`Unknown Bref layer named "${layerName}".\nIs that a typo? Check out https://bref.sh/docs/runtimes/ to see the correct name of Bref layers.`);
-                    }
-                    if (!(region in layers[layerName])) {
-                        throw new serverless.classes.Error(`There is no Bref layer named "${layerName}" in region "${region}".\nThat region may not be supported yet. Check out https://runtimes.bref.sh to see the list of supported regions.\nOpen an issue to ask for that region to be supported: https://github.com/brefphp/bref/issues`);
-                    }
-                    const version = layers[layerName][region];
-                    return `arn:aws:lambda:${region}:209497400698:layer:${layerName}:${version}`;
-                }
-
-                return delegate(variableString);
-            }
-        }
+        this.commands = {
+            'bref:cli': {
+                usage: 'Runs a CLI command in AWS Lambda',
+                lifecycleEvents: ['run'],
+                options: {
+                    // Define the '--args' option with the '-a' shortcut
+                    args: {
+                        usage: 'Specify the arguments/options of the command to run on AWS Lambda',
+                        shortcut: 'a',
+                        type: 'string',
+                    },
+                },
+            },
+            'bref:local': {
+                usage: 'Runs a PHP Lambda function locally (better alternative to "serverless local")',
+                lifecycleEvents: ['run'],
+                options: {
+                    function: {
+                        usage: 'The name of the function to invoke',
+                        shortcut: 'f',
+                        required: true,
+                        type: 'string',
+                    },
+                    data: {
+                        usage: 'The data (as a JSON string) to pass to the handler',
+                        shortcut: 'd',
+                        type: 'string',
+                    },
+                    path: {
+                        usage: 'Path to JSON or YAML file holding input data (use either this or --data)',
+                        shortcut: 'p',
+                        type: 'string',
+                    },
+                },
+            },
+            'bref:layers': {
+                usage: 'Displays the versions of the Bref layers',
+                lifecycleEvents: ['show'],
+            },
+        };
 
+        // noinspection JSUnusedGlobalSymbols
         this.hooks = {
             'initialize': () => {
-                this.addCustomIamRoleForVendorArchiveDownload();
+                this.processPhpRuntimes();
+                warnIfUsingSecretsWithoutTheBrefDependency(this.serverless, utils.log);
                 try {
                     this.telemetry();
                 } catch (e) {
                     // These errors should not stop the execution
-                    this.logVerbose(`Could not send telemetry: ${e}`);
                 }
             },
-
-            // Separate vendor for `sls deploy` command
-            'package:setupProviderConfiguration': this.createVendorZip.bind(this),
-            'after:aws:deploy:deploy:createStack': this.uploadVendorZip.bind(this),
-            // Separate vendor for `sls deploy function` command
-            'before:deploy:function:initialize': this.createVendorZip.bind(this),
-            'after:deploy:function:initialize': this.uploadVendorZip.bind(this),
-
-            'before:remove:remove': this.removeVendorArchives.bind(this)
+            // Custom commands
+            'bref:cli:run': () => runConsole(this.serverless, options),
+            'bref:local:run': () => runLocal(this.serverless, options),
+            'bref:layers:show': () => listLayers(this.serverless, utils.log),
         };
     }
 
-    checkCompatibleRuntime() {
-        if (this.serverless.service.provider.runtime === 'provided') {
-            throw new this.serverless.classes.Error('Bref 1.0 layers are not compatible with the "provided" runtime.\nTo upgrade to Bref 1.0, you have to switch to "provided.al2" in serverless.yml.\nMore details here: https://bref.sh/docs/news/01-bref-1.0.html#amazon-linux-2');
-        }
-        for (const [name, f] of Object.entries(this.serverless.service.functions)) {
-            if (f.runtime === 'provided') {
-                throw new this.serverless.classes.Error(`Bref 1.0 layers are not compatible with the "provided" runtime.\nTo upgrade to Bref 1.0, you have to switch to "provided.al2" in serverless.yml for the function "${name}".\nMore details here: https://bref.sh/docs/news/01-bref-1.0.html#amazon-linux-2`);
+    /**
+     * Process the `php-xx` runtimes to turn them into `provided.al2` runtimes + Bref layers.
+     */
+    processPhpRuntimes() {
+        const includeBrefLayers = (runtime, existingLayers, isArm) => {
+            let layerName = runtime;
+            // Automatically use ARM layers if the function is deployed to an ARM architecture
+            if (isArm) {
+                layerName = 'arm-' + layerName;
             }
+            if (layerName.endsWith('-console')) {
+                layerName = layerName.substring(0, layerName.length - '-console'.length);
+                existingLayers.unshift(this.getLayerArn('console', this.provider.getRegion()));
+                existingLayers.unshift(this.getLayerArn(layerName, this.provider.getRegion()));
+            } else {
+                existingLayers.unshift(this.getLayerArn(layerName, this.provider.getRegion()));
+            }
+            return existingLayers;
         }
-    }
-
-    addCustomIamRoleForVendorArchiveDownload() {
-        this.serverless.service.custom = this.serverless.service.custom ? this.serverless.service.custom : {};
-        this.serverless.service.custom.bref = this.serverless.service.custom.bref ? this.serverless.service.custom.bref : {};
-        if (! this.serverless.service.custom.bref.separateVendor) {
-            return;
-        }
-
-        this.logVerbose("Adding custom IAM role for vendor archive");
-
-        // If the serverless config does not yet contain an exclude for the vendor folder
-        // we will add it here as we do not want the vendor folder in our
-        // lambda archive file.
-        let excludes = this.serverless.service.package.exclude;
-        if(excludes && excludes.indexOf('vendor/**') === -1) {
-            excludes.push('vendor/**');
-        }
-        let patterns = this.serverless.service.package.patterns;
-        if(patterns && patterns.indexOf('!vendor/**') === -1) {
-            patterns.push('!vendor/**');
-        }
-
-        // This defines the access rights for Lambda, so it can download the
-        // vendor archive file from the vendors subfolder.
-        this.serverless.service.provider.iamRoleStatements = this.serverless.service.provider.iamRoleStatements ? this.serverless.service.provider.iamRoleStatements : [];
-
-        this.serverless.service.provider.iamRoleStatements.push({
-            Effect: 'Allow',
-            Action: [
-                's3:GetObject',
-            ],
-            Resource: [
-                {
-                    'Fn::Join': [
-                        '',
-                        [
-                            'arn:aws:s3:::',
-                            {
-                                'Ref': 'ServerlessDeploymentBucket'
-                            },
-                            '/',
-                            this.stripSlashes(this.provider.getDeploymentPrefix() + '/vendors/*')
-                        ]
-                    ]
-                }
-            ]
-        });
-    }
-
-    async createVendorZip() {
-        this.serverless.service.custom = this.serverless.service.custom ? this.serverless.service.custom : {};
-        this.serverless.service.custom.bref = this.serverless.service.custom.bref ? this.serverless.service.custom.bref : {};
-        if(! this.serverless.service.custom.bref.separateVendor) {
-            return;
-        }
-
-        this.logVerbose("Creating vendor zip file");
-
-        const vendorZipHash = await this.createZipFile();
-        this.newVendorZipName = vendorZipHash + '.zip';
-
-        this.logVerbose('Setting environment variables');
-
-        if (! this.serverless.service.provider.environment) {
-            this.serverless.service.provider.environment = [];
-        }
-
-        // This environment variable will trigger Bref to download the zip on cold start
-        this.serverless.service.provider.environment.BREF_DOWNLOAD_VENDOR = {
-            'Fn::Join': [
-                '',
-                [
-                    's3://',
-                    {
-                        'Ref': 'ServerlessDeploymentBucket'
-                    },
-                    '/',
-                    this.stripSlashes(this.provider.getDeploymentPrefix() + '/vendors/' + this.newVendorZipName)
-                ]
-            ]
-        };
-    }
 
-    async createZipFile() {
-        const vendorDir = '.serverless';
-        if (!this.fs.existsSync(vendorDir)){
-            this.fs.mkdirSync(vendorDir);
+        const config = this.serverless.service;
+        const isArmGlobally = config.provider.architecture === 'arm64';
+
+        // Check provider config
+        if (this.runtimes.includes(config.provider.runtime ?? '')) {
+            config.provider.layers = includeBrefLayers(
+                config.provider.runtime,
+                config.provider.layers || [], // make sure it's an array
+                isArmGlobally,
+            );
+            config.provider.runtime = 'provided.al2';
         }
-        this.filePath = `${vendorDir}/vendor.zip`;
-
-        return await new Promise((resolve, reject) => {
-            const archiver = require(process.mainModule.path + '/../node_modules/archiver');
-            const output = this.fs.createWriteStream(this.filePath);
-            const archive = archiver('zip', {
-                zlib: { level: 9 } // Highest compression level.
-            });
-
-            this.logVerbose(`Packaging the Composer vendor directory in ${this.filePath}`);
-
-            archive.pipe(output);
-            archive.directory('vendor/', false);
-            archive.finalize();
-
-            output.on('close', () => {
-                this.logVerbose(`Created vendor.zip with ${archive.pointer()} total bytes.`);
-                resolve();
-            });
-
-            archive.on('warning', err => {
-                if (err.code === 'ENOENT') {
-                    this.logWarning('Archiver warning', err);
-                } else {
-                    throw new Error(err);
-                }
-            });
 
-            archive.on('error', err => {
-                throw new Error(err);
-            });
-        })
-            .then(() => {
-                // We will rename vendor.zip to a unique name to:
-                // - avoid overwriting zips from previous deployments running in production
-                // - avoid deploying vendor.zip with exactly the same contents
-                const crypto = require('crypto');
-
-                return new Promise(resolve => {
-                    const hash = crypto.createHash('md5');
-                    this.fs.createReadStream(this.filePath).on('data', data => hash.update(data)).on('end', () => resolve(hash.digest('hex')));
-                });
-            })
-            .catch(err => {
-                throw new Error(`Failed to create zip file vendor.zip: ${err.message}`);
-            });
-    }
-
-    async uploadVendorZip() {
-        this.serverless.service.custom = this.serverless.service.custom ? this.serverless.service.custom : {};
-        this.serverless.service.custom.bref = this.serverless.service.custom.bref ? this.serverless.service.custom.bref : {};
-        if(! this.serverless.service.custom.bref.separateVendor) {
-            return;
+        // Check functions config
+        for (const f of Object.values(config.functions ?? {})) {
+            if (this.runtimes.includes(f.runtime)) {
+                f.layers = includeBrefLayers(
+                    f.runtime,
+                    f.layers || [], // make sure it's an array
+                    f.architecture === 'arm64' || (isArmGlobally && !f.architecture),
+                );
+                f.runtime = 'provided.al2';
+            }
         }
 
-        this.logVerbose("Uploading vendor zip file...");
-
-        await this.uploadZipToS3(this.filePath);
-
-        this.logVerbose('Vendor separation done');
-    }
-
-    async uploadZipToS3(zipFile) {
-        const bucketName = await this.provider.getServerlessDeploymentBucketName();
-        const deploymentPrefix = await this.provider.getDeploymentPrefix();
-
-        this.logVerbose('Checking vendor file on bucket...');
-
-        try {
-            await this.provider.request('S3', 'headObject', {
-                Bucket: bucketName,
-                Key: this.stripSlashes(deploymentPrefix + '/vendors/' + this.newVendorZipName)
-            });
-
-            this.logVerbose('Vendor file already exists on bucket. Not uploading again.');
-            return;
-        } catch(e) {
-            // The vendor file needs to be uploaded
-            this.logVerbose('Vendor file not found. Uploading...');
+        // Check Lift constructs config
+        for (const construct of Object.values(this.serverless.configurationInput.constructs ?? {})) {
+            if (construct.type !== 'queue' && construct.type !== 'webhook') continue;
+            const f = construct.type === 'queue' ? construct.worker : construct.authorizer;
+            if (f && this.runtimes.includes(f.runtime)) {
+                f.layers = includeBrefLayers(
+                    f.runtime,
+                    f.layers || [], // make sure it's an array
+                    f.architecture === 'arm64' || (isArmGlobally && !f.architecture),
+                );
+                f.runtime = 'provided.al2';
+            }
         }
-
-        const readStream = this.fs.createReadStream(zipFile);
-        const details = {
-            ACL: 'private',
-            Body: readStream,
-            Bucket: bucketName,
-            ContentType: 'application/zip',
-            Key: this.stripSlashes(deploymentPrefix + '/vendors/' + this.newVendorZipName),
-        };
-
-        return await this.provider.request('S3', 'putObject', details);
     }
 
-    stripSlashes(path) {
-        return path.replace(/^\/+/g, '');
-    }
-
-    /**
-     * CloudFormation cannot delete a bucket that contains files.
-     * That's why we clean up the vendor zip files when `serverless remove` is being run.
-     */
-    async removeVendorArchives() {
-        const bucketName = await this.provider.getServerlessDeploymentBucketName();
-        const deploymentPrefix = await this.provider.getDeploymentPrefix();
-
-        const bucketObjects = await this.provider.request('S3', 'listObjectsV2', {
-            Bucket: bucketName,
-            Prefix: this.stripSlashes(deploymentPrefix + '/vendors/')
-        });
-        if (bucketObjects.Contents.length === 0) {
-            return;
+    checkCompatibleRuntime() {
+        const errorMessage = 'Bref layers are not compatible with the "provided" runtime.\nYou have to use the "provided.al2" runtime instead in serverless.yml.\nMore details here: https://bref.sh/docs/news/01-bref-1.0.html#amazon-linux-2';
+        if (this.serverless.service.provider.runtime === 'provided') {
+            throw new this.serverless.classes.Error(errorMessage);
         }
-
-        this.logVerbose('Removing Composer `vendor` archives from the S3 bucket.');
-
-        let details = {
-            Bucket: bucketName,
-            Delete: {
-                Objects: []
+        for (const [, f] of Object.entries(this.serverless.service.functions)) {
+            if (f.runtime === 'provided') {
+                throw new this.serverless.classes.Error(errorMessage);
             }
-        };
-
-        bucketObjects.Contents.forEach(content => {
-            details.Delete.Objects.push({
-                Key: content.Key
-            });
-        });
-
-        this.logVerbose(`Found ${details.Delete.Objects.length} vendor archives. Removing them from Bucket now.`);
-
-        return await this.provider.request('S3', 'deleteObjects', details);
-    }
-
-    logVerbose(message) {
-        if (this.utils) {
-            // Serverless v3
-            this.utils.log.verbose(`Bref: ${message}`);
-        } else {
-            // Serverless v2
-            this.serverless.cli.log(`Bref: ${message}`);
         }
     }
 
-    logWarning(message) {
-        if (this.utils) {
-            // Serverless v3
-            this.utils.log.warning(`Bref: ${message}`);
-        } else {
-            // Serverless v2
-            console.warn(`Bref: ${message}`);
+    getLayerArn(layerName, region) {
+        if (! (layerName in this.layers)) {
+            throw new this.serverless.classes.Error(`Unknown Bref layer named "${layerName}".\nIs that a typo? Check out https://bref.sh/docs/runtimes/ to see the correct name of Bref layers.`);
+        }
+        if (! (region in this.layers[layerName])) {
+            throw new this.serverless.classes.Error(`There is no Bref layer named "${layerName}" in region "${region}".\nThat region may not be supported yet. Check out https://runtimes.bref.sh to see the list of supported regions.\nOpen an issue to ask for that region to be supported: https://github.com/brefphp/bref/issues`);
         }
+        const version = this.layers[layerName][region];
+        return `arn:aws:lambda:${region}:534081306603:layer:${layerName}:${version}`;
     }
 
     /**
@@ -375,7 +236,7 @@ class ServerlessPlugin {
 
         const payload = {
             cli: 'sls',
-            v: 1, // Bref version
+            v: 2, // Bref version
             c: command,
             ci: ci.isCI,
             install: userConfig.get('meta.created_at'),
@@ -399,9 +260,13 @@ class ServerlessPlugin {
         // or execution time.
         client.send(JSON.stringify(payload), 8888, '108.128.197.71', (err) => {
             if (err) {
-                this.logVerbose(`Could not send telemetry: ${err.message}`);
+                // These errors should not stop the execution
+            }
+            try {
+                client.close();
+            } catch (e) {
+                // These errors should not stop the execution
             }
-            client.close();
         });
     }
 }
diff --git a/layers.json b/layers.json
index 6c2c8fef6..a2ef155fa 100644
--- a/layers.json
+++ b/layers.json
@@ -1,266 +1,314 @@
 {
     "php-82": {
-        "ca-central-1": "21",
-        "eu-central-1": "19",
-        "eu-north-1": "19",
-        "eu-west-1": "19",
-        "eu-west-2": "19",
-        "eu-west-3": "19",
-        "sa-east-1": "19",
-        "us-east-1": "19",
-        "us-east-2": "19",
-        "us-west-1": "19",
-        "us-west-2": "19",
-        "ap-east-1": "19",
-        "ap-south-1": "19",
-        "ap-northeast-1": "19",
-        "ap-northeast-2": "19",
-        "ap-northeast-3": "19",
-        "ap-southeast-1": "19",
-        "ap-southeast-2": "19",
-        "eu-south-1": "19",
-        "eu-south-2": "9",
-        "af-south-1": "19",
-        "me-south-1": "19"
+        "ca-central-1": "23",
+        "eu-central-1": "23",
+        "eu-north-1": "23",
+        "eu-west-1": "23",
+        "eu-west-2": "23",
+        "eu-west-3": "23",
+        "sa-east-1": "23",
+        "us-east-1": "23",
+        "us-east-2": "23",
+        "us-west-1": "23",
+        "us-west-2": "23",
+        "ap-east-1": "23",
+        "ap-south-1": "23",
+        "ap-northeast-1": "23",
+        "ap-northeast-2": "23",
+        "ap-northeast-3": "23",
+        "ap-southeast-1": "23",
+        "ap-southeast-2": "23",
+        "eu-south-1": "23",
+        "eu-south-2": "22",
+        "af-south-1": "23",
+        "me-south-1": "23"
     },
     "php-82-fpm": {
-        "ca-central-1": "20",
-        "eu-central-1": "19",
-        "eu-north-1": "19",
-        "eu-west-1": "19",
-        "eu-west-2": "19",
-        "eu-west-3": "19",
-        "sa-east-1": "19",
-        "us-east-1": "19",
-        "us-east-2": "19",
-        "us-west-1": "19",
-        "us-west-2": "19",
-        "ap-east-1": "19",
-        "ap-south-1": "19",
-        "ap-northeast-1": "19",
-        "ap-northeast-2": "19",
-        "ap-northeast-3": "19",
-        "ap-southeast-1": "19",
-        "ap-southeast-2": "19",
-        "eu-south-1": "19",
-        "eu-south-2": "9",
-        "af-south-1": "19",
-        "me-south-1": "19"
+        "ca-central-1": "23",
+        "eu-central-1": "23",
+        "eu-north-1": "23",
+        "eu-west-1": "23",
+        "eu-west-2": "23",
+        "eu-west-3": "23",
+        "sa-east-1": "23",
+        "us-east-1": "23",
+        "us-east-2": "23",
+        "us-west-1": "23",
+        "us-west-2": "23",
+        "ap-east-1": "23",
+        "ap-south-1": "23",
+        "ap-northeast-1": "23",
+        "ap-northeast-2": "23",
+        "ap-northeast-3": "23",
+        "ap-southeast-1": "23",
+        "ap-southeast-2": "23",
+        "eu-south-1": "23",
+        "eu-south-2": "22",
+        "af-south-1": "23",
+        "me-south-1": "23"
     },
     "php-81": {
-        "ca-central-1": "45",
-        "eu-central-1": "44",
-        "eu-north-1": "44",
-        "eu-west-1": "44",
-        "eu-west-2": "44",
-        "eu-west-3": "44",
-        "sa-east-1": "44",
-        "us-east-1": "44",
-        "us-east-2": "44",
-        "us-west-1": "44",
-        "us-west-2": "44",
-        "ap-east-1": "33",
-        "ap-south-1": "43",
-        "ap-northeast-1": "43",
-        "ap-northeast-2": "43",
-        "ap-northeast-3": "33",
-        "ap-southeast-1": "43",
-        "ap-southeast-2": "43",
-        "eu-south-1": "43",
-        "eu-south-2": "9",
-        "af-south-1": "43",
-        "me-south-1": "33"
+        "ca-central-1": "33",
+        "eu-central-1": "33",
+        "eu-north-1": "33",
+        "eu-west-1": "33",
+        "eu-west-2": "33",
+        "eu-west-3": "33",
+        "sa-east-1": "33",
+        "us-east-1": "33",
+        "us-east-2": "33",
+        "us-west-1": "33",
+        "us-west-2": "33",
+        "ap-east-1": "25",
+        "ap-south-1": "32",
+        "ap-northeast-1": "32",
+        "ap-northeast-2": "32",
+        "ap-northeast-3": "32",
+        "ap-southeast-1": "32",
+        "ap-southeast-2": "32",
+        "eu-south-1": "25",
+        "eu-south-2": "22",
+        "af-south-1": "25",
+        "me-south-1": "25"
     },
     "php-81-fpm": {
-        "ca-central-1": "46",
-        "eu-central-1": "44",
-        "eu-north-1": "44",
-        "eu-west-1": "44",
-        "eu-west-2": "44",
-        "eu-west-3": "44",
-        "sa-east-1": "44",
-        "us-east-1": "44",
-        "us-east-2": "44",
-        "us-west-1": "44",
-        "us-west-2": "44",
-        "ap-east-1": "33",
-        "ap-south-1": "43",
-        "ap-northeast-1": "43",
-        "ap-northeast-2": "43",
-        "ap-northeast-3": "33",
-        "ap-southeast-1": "43",
-        "ap-southeast-2": "43",
-        "eu-south-1": "43",
-        "eu-south-2": "9",
-        "af-south-1": "43",
-        "me-south-1": "33"
+        "ca-central-1": "33",
+        "eu-central-1": "33",
+        "eu-north-1": "33",
+        "eu-west-1": "33",
+        "eu-west-2": "33",
+        "eu-west-3": "33",
+        "sa-east-1": "33",
+        "us-east-1": "33",
+        "us-east-2": "33",
+        "us-west-1": "33",
+        "us-west-2": "33",
+        "ap-east-1": "25",
+        "ap-south-1": "32",
+        "ap-northeast-1": "32",
+        "ap-northeast-2": "32",
+        "ap-northeast-3": "32",
+        "ap-southeast-1": "32",
+        "ap-southeast-2": "32",
+        "eu-south-1": "25",
+        "eu-south-2": "22",
+        "af-south-1": "25",
+        "me-south-1": "25"
     },
     "php-80": {
-        "ca-central-1": "58",
-        "eu-central-1": "58",
-        "eu-north-1": "58",
-        "eu-west-1": "58",
-        "eu-west-2": "58",
-        "eu-west-3": "58",
-        "sa-east-1": "58",
-        "us-east-1": "58",
-        "us-east-2": "58",
-        "us-west-1": "58",
-        "us-west-2": "58",
-        "ap-east-1": "33",
-        "ap-south-1": "57",
-        "ap-northeast-1": "57",
-        "ap-northeast-2": "57",
-        "ap-northeast-3": "33",
-        "ap-southeast-1": "57",
-        "ap-southeast-2": "57",
-        "eu-south-1": "55",
-        "eu-south-2": "9",
-        "af-south-1": "55",
-        "me-south-1": "33"
+        "ca-central-1": "35",
+        "eu-central-1": "35",
+        "eu-north-1": "35",
+        "eu-west-1": "35",
+        "eu-west-2": "35",
+        "eu-west-3": "35",
+        "sa-east-1": "35",
+        "us-east-1": "35",
+        "us-east-2": "35",
+        "us-west-1": "35",
+        "us-west-2": "35",
+        "ap-east-1": "25",
+        "ap-south-1": "34",
+        "ap-northeast-1": "32",
+        "ap-northeast-2": "32",
+        "ap-northeast-3": "32",
+        "ap-southeast-1": "32",
+        "ap-southeast-2": "34",
+        "eu-south-1": "25",
+        "eu-south-2": "22",
+        "af-south-1": "25",
+        "me-south-1": "25"
     },
     "php-80-fpm": {
-        "ca-central-1": "58",
-        "eu-central-1": "58",
-        "eu-north-1": "58",
-        "eu-west-1": "58",
-        "eu-west-2": "58",
-        "eu-west-3": "58",
-        "sa-east-1": "58",
-        "us-east-1": "58",
-        "us-east-2": "58",
-        "us-west-1": "58",
-        "us-west-2": "58",
-        "ap-east-1": "33",
-        "ap-south-1": "57",
-        "ap-northeast-1": "57",
-        "ap-northeast-2": "57",
-        "ap-northeast-3": "33",
-        "ap-southeast-1": "57",
-        "ap-southeast-2": "57",
-        "eu-south-1": "55",
-        "eu-south-2": "9",
-        "af-south-1": "55",
-        "me-south-1": "33"
+        "ca-central-1": "33",
+        "eu-central-1": "33",
+        "eu-north-1": "33",
+        "eu-west-1": "33",
+        "eu-west-2": "33",
+        "eu-west-3": "33",
+        "sa-east-1": "33",
+        "us-east-1": "33",
+        "us-east-2": "33",
+        "us-west-1": "33",
+        "us-west-2": "33",
+        "ap-east-1": "25",
+        "ap-south-1": "32",
+        "ap-northeast-1": "32",
+        "ap-northeast-2": "32",
+        "ap-northeast-3": "32",
+        "ap-southeast-1": "32",
+        "ap-southeast-2": "32",
+        "eu-south-1": "25",
+        "eu-south-2": "22",
+        "af-south-1": "25",
+        "me-south-1": "25"
     },
-    "php-74": {
-        "ca-central-1": "69",
-        "eu-central-1": "69",
-        "eu-north-1": "70",
-        "eu-west-1": "69",
-        "eu-west-2": "69",
-        "eu-west-3": "69",
-        "sa-east-1": "69",
-        "us-east-1": "69",
-        "us-east-2": "69",
-        "us-west-1": "69",
-        "us-west-2": "69",
-        "ap-east-1": "33",
-        "ap-south-1": "67",
-        "ap-northeast-1": "67",
-        "ap-northeast-2": "67",
-        "ap-northeast-3": "33",
-        "ap-southeast-1": "67",
-        "ap-southeast-2": "67",
-        "eu-south-1": "56",
-        "eu-south-2": "9",
-        "af-south-1": "56",
-        "me-south-1": "33"
+    "arm-php-82": {
+        "ca-central-1": "10",
+        "eu-central-1": "10",
+        "eu-north-1": "10",
+        "eu-west-1": "10",
+        "eu-west-2": "10",
+        "eu-west-3": "10",
+        "sa-east-1": "10",
+        "us-east-1": "10",
+        "us-east-2": "10",
+        "us-west-1": "10",
+        "us-west-2": "10",
+        "ap-east-1": "10",
+        "ap-south-1": "10",
+        "ap-northeast-1": "10",
+        "ap-northeast-2": "10",
+        "ap-northeast-3": "10",
+        "ap-southeast-1": "10",
+        "ap-southeast-2": "10",
+        "eu-south-1": "10",
+        "eu-south-2": "10",
+        "af-south-1": "10",
+        "me-south-1": "10"
     },
-    "php-74-fpm": {
-        "ca-central-1": "69",
-        "eu-central-1": "69",
-        "eu-north-1": "70",
-        "eu-west-1": "69",
-        "eu-west-2": "69",
-        "eu-west-3": "69",
-        "sa-east-1": "69",
-        "us-east-1": "69",
-        "us-east-2": "69",
-        "us-west-1": "69",
-        "us-west-2": "69",
-        "ap-east-1": "33",
-        "ap-south-1": "67",
-        "ap-northeast-1": "67",
-        "ap-northeast-2": "67",
-        "ap-northeast-3": "33",
-        "ap-southeast-1": "67",
-        "ap-southeast-2": "67",
-        "eu-south-1": "56",
-        "eu-south-2": "9",
-        "af-south-1": "56",
-        "me-south-1": "33"
+    "arm-php-82-fpm": {
+        "ca-central-1": "10",
+        "eu-central-1": "10",
+        "eu-north-1": "10",
+        "eu-west-1": "10",
+        "eu-west-2": "10",
+        "eu-west-3": "10",
+        "sa-east-1": "10",
+        "us-east-1": "10",
+        "us-east-2": "10",
+        "us-west-1": "10",
+        "us-west-2": "10",
+        "ap-east-1": "10",
+        "ap-south-1": "10",
+        "ap-northeast-1": "10",
+        "ap-northeast-2": "10",
+        "ap-northeast-3": "10",
+        "ap-southeast-1": "10",
+        "ap-southeast-2": "10",
+        "eu-south-1": "10",
+        "eu-south-2": "10",
+        "af-south-1": "10",
+        "me-south-1": "10"
     },
-    "php-73": {
-        "ca-central-1": "83",
-        "eu-central-1": "83",
-        "eu-north-1": "82",
-        "eu-west-1": "83",
-        "eu-west-2": "83",
-        "eu-west-3": "83",
-        "sa-east-1": "83",
-        "us-east-1": "83",
-        "us-east-2": "83",
-        "us-west-1": "84",
-        "us-west-2": "83",
-        "ap-east-1": "33",
-        "ap-south-1": "81",
-        "ap-northeast-1": "81",
-        "ap-northeast-2": "81",
-        "ap-northeast-3": "33",
-        "ap-southeast-1": "81",
-        "ap-southeast-2": "81",
-        "eu-south-1": "56",
-        "eu-south-2": "9",
-        "af-south-1": "56",
-        "me-south-1": "33"
+    "arm-php-81": {
+        "ca-central-1": "13",
+        "eu-central-1": "13",
+        "eu-north-1": "13",
+        "eu-west-1": "13",
+        "eu-west-2": "13",
+        "eu-west-3": "13",
+        "sa-east-1": "13",
+        "us-east-1": "13",
+        "us-east-2": "13",
+        "us-west-1": "13",
+        "us-west-2": "13",
+        "ap-east-1": "13",
+        "ap-south-1": "13",
+        "ap-northeast-1": "13",
+        "ap-northeast-2": "13",
+        "ap-northeast-3": "13",
+        "ap-southeast-1": "13",
+        "ap-southeast-2": "13",
+        "eu-south-1": "13",
+        "eu-south-2": "13",
+        "af-south-1": "13",
+        "me-south-1": "13"
+    },
+    "arm-php-81-fpm": {
+        "ca-central-1": "13",
+        "eu-central-1": "13",
+        "eu-north-1": "13",
+        "eu-west-1": "13",
+        "eu-west-2": "13",
+        "eu-west-3": "13",
+        "sa-east-1": "13",
+        "us-east-1": "13",
+        "us-east-2": "13",
+        "us-west-1": "13",
+        "us-west-2": "13",
+        "ap-east-1": "13",
+        "ap-south-1": "13",
+        "ap-northeast-1": "13",
+        "ap-northeast-2": "13",
+        "ap-northeast-3": "13",
+        "ap-southeast-1": "13",
+        "ap-southeast-2": "13",
+        "eu-south-1": "13",
+        "eu-south-2": "13",
+        "af-south-1": "13",
+        "me-south-1": "13"
     },
-    "php-73-fpm": {
-        "ca-central-1": "83",
-        "eu-central-1": "83",
-        "eu-north-1": "82",
-        "eu-west-1": "83",
-        "eu-west-2": "83",
-        "eu-west-3": "83",
-        "sa-east-1": "83",
-        "us-east-1": "83",
-        "us-east-2": "83",
-        "us-west-1": "83",
-        "us-west-2": "83",
-        "ap-east-1": "33",
-        "ap-south-1": "81",
-        "ap-northeast-1": "81",
-        "ap-northeast-2": "81",
+    "arm-php-80": {
+        "ca-central-1": "34",
+        "eu-central-1": "33",
+        "eu-north-1": "34",
+        "eu-west-1": "34",
+        "eu-west-2": "34",
+        "eu-west-3": "34",
+        "sa-east-1": "34",
+        "us-east-1": "34",
+        "us-east-2": "34",
+        "us-west-1": "34",
+        "us-west-2": "34",
+        "ap-east-1": "26",
+        "ap-south-1": "33",
+        "ap-northeast-1": "33",
+        "ap-northeast-2": "33",
         "ap-northeast-3": "33",
-        "ap-southeast-1": "81",
-        "ap-southeast-2": "81",
-        "eu-south-1": "56",
-        "eu-south-2": "9",
-        "af-south-1": "56",
-        "me-south-1": "33"
+        "ap-southeast-1": "33",
+        "ap-southeast-2": "33",
+        "eu-south-1": "26",
+        "eu-south-2": "22",
+        "af-south-1": "26",
+        "me-south-1": "26"
+    },
+    "arm-php-80-fpm": {
+        "ca-central-1": "33",
+        "eu-central-1": "33",
+        "eu-north-1": "33",
+        "eu-west-1": "33",
+        "eu-west-2": "33",
+        "eu-west-3": "33",
+        "sa-east-1": "33",
+        "us-east-1": "33",
+        "us-east-2": "33",
+        "us-west-1": "33",
+        "us-west-2": "33",
+        "ap-east-1": "25",
+        "ap-south-1": "32",
+        "ap-northeast-1": "32",
+        "ap-northeast-2": "32",
+        "ap-northeast-3": "32",
+        "ap-southeast-1": "32",
+        "ap-southeast-2": "32",
+        "eu-south-1": "25",
+        "eu-south-2": "22",
+        "af-south-1": "25",
+        "me-south-1": "25"
     },
     "console": {
-        "ca-central-1": "83",
-        "eu-central-1": "83",
-        "eu-north-1": "82",
-        "eu-west-1": "83",
-        "eu-west-2": "83",
-        "eu-west-3": "83",
-        "sa-east-1": "83",
-        "us-east-1": "83",
-        "us-east-2": "83",
-        "us-west-1": "83",
-        "us-west-2": "83",
-        "ap-east-1": "33",
-        "ap-south-1": "82",
-        "ap-northeast-1": "82",
-        "ap-northeast-2": "82",
-        "ap-northeast-3": "33",
-        "ap-southeast-1": "82",
-        "ap-southeast-2": "82",
-        "eu-south-1": "56",
-        "eu-south-2": "9",
-        "af-south-1": "56",
-        "me-south-1": "33"
+        "ca-central-1": "33",
+        "eu-central-1": "33",
+        "eu-north-1": "33",
+        "eu-west-1": "33",
+        "eu-west-2": "33",
+        "eu-west-3": "33",
+        "sa-east-1": "33",
+        "us-east-1": "33",
+        "us-east-2": "33",
+        "us-west-1": "33",
+        "us-west-2": "33",
+        "ap-east-1": "25",
+        "ap-south-1": "32",
+        "ap-northeast-1": "32",
+        "ap-northeast-2": "32",
+        "ap-northeast-3": "32",
+        "ap-southeast-1": "32",
+        "ap-southeast-2": "32",
+        "eu-south-1": "25",
+        "eu-south-2": "22",
+        "af-south-1": "25",
+        "me-south-1": "25"
     }
 }
\ No newline at end of file
diff --git a/phpstan.neon b/phpstan.neon
index da35e391d..bcea319ff 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -5,16 +5,6 @@ parameters:
     - src
     - tests
   excludePaths:
-    - %rootDir%/../../../tests/Bridge/Laravel/bootstrap/cache/*
-    - %rootDir%/../../../tests/Bridge/Laravel/storage/*
-    - %rootDir%/../../../tests/Bridge/Symfony/var/*
-    - %rootDir%/../../../tests/Bridge/Symfony/cache/*
-    - %rootDir%/../../../tests/Bridge/Symfony/logs/*
     - %rootDir%/../../../tests/Sam/Php/*
     - %rootDir%/../../../tests/Sam/PhpFpm/*
-    - %rootDir%/../../../tests/Functional/fpm/*
-
-  ignoreErrors:
-    -
-      message: '#Parameter \#1 \$handler of method Bref\\Runtime\\LambdaRuntime::processNextEvent\(\).*#'
-      path: tests/Runtime/LambdaRuntimeTest.php
+    - %rootDir%/../../../tests/FpmRuntime/Functional/fpm/*
diff --git a/plugin/layers.js b/plugin/layers.js
new file mode 100644
index 000000000..e17dd2ef2
--- /dev/null
+++ b/plugin/layers.js
@@ -0,0 +1,25 @@
+const fs = require('fs');
+const path = require('path');
+
+function listLayers(serverless, log) {
+    const region = serverless.getProvider("aws").getRegion();
+
+    const json = fs.readFileSync(path.join(__dirname, '../layers.json'));
+    const layers = JSON.parse(json.toString());
+    log(`Layers for the ${region} region:`);
+
+    log();
+    log('Layer        Version   ARN');
+    log('----------------------------------------------------------------------------------');
+    for (const [layer, versions] of Object.entries(layers)) {
+        const version = versions[region];
+        const arn = `arn:aws:lambda:${region}:534081306603:layer:${layer}:${version}`;
+        log(`${padString(layer, 12)} ${padString(version, 9)} ${arn}`);
+    }
+}
+
+function padString(str, length) {
+    return str.padEnd(length, ' ');
+}
+
+module.exports = {listLayers};
diff --git a/plugin/local.js b/plugin/local.js
new file mode 100644
index 000000000..7c0c6b120
--- /dev/null
+++ b/plugin/local.js
@@ -0,0 +1,27 @@
+const {spawnSync} = require('child_process');
+const path = require('path');
+const fs = require('fs');
+
+function runLocal(serverless, options) {
+    if (options.data && options.path) {
+        throw new serverless.classes.Error('You cannot provide both --data and --path');
+    }
+
+    let data = options.data;
+    if (!data && options.path) {
+        data = fs.readFileSync(options.path).toString();
+    }
+
+    const fn = serverless.service.getFunction(options.function);
+
+    const args = [
+        path.join(__dirname, '../src/bref-local'),
+        fn.handler,
+        data ?? '',
+    ];
+    spawnSync('php', args, {
+        stdio: 'inherit',
+    });
+}
+
+module.exports = {runLocal};
diff --git a/plugin/run-console.js b/plugin/run-console.js
new file mode 100644
index 000000000..cd764cc87
--- /dev/null
+++ b/plugin/run-console.js
@@ -0,0 +1,41 @@
+const fs = require('fs');
+const path = require('path');
+
+async function runConsole(serverless, options) {
+    const region = serverless.getProvider('aws').getRegion();
+    // Override CLI options for `sls invoke`
+    options.function = getConsoleFunction(serverless, region);
+    options.type = 'RequestResponse';
+    options.data = options.args;
+    options.log = true;
+    // Run `sls invoke`
+    await serverless.pluginManager.spawn('invoke');
+}
+
+function getConsoleFunction(serverless, region) {
+    const consoleLayerArn = getConsoleLayerArn(region);
+
+    const functions = serverless.service.functions;
+    const consoleFunctions = [];
+    for (const [functionName, functionDetails] of Object.entries(functions)) {
+        if (functionDetails.layers.includes(consoleLayerArn)) {
+            consoleFunctions.push(functionName);
+        }
+    }
+    if (consoleFunctions.length === 0) {
+        throw new serverless.classes.Error('This command invokes the Lambda "console" function, but no function was found with the "console" layer');
+    }
+    if (consoleFunctions.length > 1) {
+        throw new serverless.classes.Error('More than one function contains the console layer: cannot automatically run it');
+    }
+    return consoleFunctions[0];
+}
+
+function getConsoleLayerArn(region) {
+    const json = fs.readFileSync(path.join(__dirname, '../layers.json'));
+    const layers = JSON.parse(json.toString());
+    const version = layers.console[region];
+    return `arn:aws:lambda:${region}:534081306603:layer:console:${version}`;
+}
+
+module.exports = {runConsole};
diff --git a/plugin/secrets.js b/plugin/secrets.js
new file mode 100644
index 000000000..b7eb408c5
--- /dev/null
+++ b/plugin/secrets.js
@@ -0,0 +1,35 @@
+const fs = require('fs');
+
+function warnIfUsingSecretsWithoutTheBrefDependency(serverless, log) {
+    const config = serverless.service;
+    const allVariables = [];
+
+    const check = ([name, value]) => {
+        if (typeof value === 'string' && value.startsWith('bref-ssm:')) {
+            allVariables.push(`${name}: ${value}`);
+        }
+    };
+    Object.entries(config.provider.environment || {}).forEach(check);
+    Object.values(config.functions).forEach(f => Object.entries(f.environment || {}).forEach(check));
+
+    if (allVariables.length > 0) {
+        // Check if the bref/secrets-loader dependency is installed in composer.json
+        if (! fs.existsSync('composer.json')) {
+            return;
+        }
+        const composerJson = JSON.parse(fs.readFileSync('composer.json', 'utf8'));
+        const dependencies = Object.keys(composerJson.require || {});
+        if (dependencies.includes('bref/secrets-loader')) {
+            return;
+        }
+
+        log.warning(`The following environment variables use the "bref-ssm:" prefix, but the "bref/secrets-loader" dependency is not installed via "composer.json".`);
+        allVariables.forEach(variable => log.warning(`    ${variable}`));
+        log.warning(`The "bref/secrets-loader" dependency is required to use the "bref-ssm:" prefix. Install it by running:`);
+        log.warning(`    composer require bref/secrets-loader`);
+        log.warning(`Learn more at https://bref.sh/docs/environment/variables.html#secrets`);
+        log.warning();
+    }
+}
+
+module.exports = {warnIfUsingSecretsWithoutTheBrefDependency};
diff --git a/runtime/Makefile b/runtime/Makefile
deleted file mode 100644
index dd1c840cb..000000000
--- a/runtime/Makefile
+++ /dev/null
@@ -1,74 +0,0 @@
-SHELL := /bin/bash
-.PHONY: publish layers docker-images
-
-# Publish the layers on AWS Lambda
-publish: layers
-	cd layers ; php publish.php
-
-# Build the layers
-layers: export/console.zip export/php-73.zip export/php-74.zip export/php-80.zip export/php-81.zip export/php-82.zip export/php-73-fpm.zip export/php-74-fpm.zip export/php-80-fpm.zip export/php-81-fpm.zip export/php-82-fpm.zip
-
-# The PHP runtimes
-export/php%.zip: docker-images
-	PHP_VERSION=$$(echo $@ | cut -d'/' -f 2 | cut -d'.' -f 1);\
-	rm -f $@;\
-	cd export ; \
-	set -e ; \
-	rm -rf opt ; \
-	CID=$$(docker create --entrypoint=scratch bref/$$PHP_VERSION:latest) ; \
-	docker cp $${CID}:/opt . ; \
-	docker rm $${CID} ; \
-	cd opt ; \
-	zip -qq -y -r - {*,.[!.]*} > ../$$PHP_VERSION.zip
-	cd ../ ; \
-	rm -rf opt ;
-
-# The console runtime
-export/console.zip: layers/console/bootstrap
-	rm -f export/console.zip
-	cd layers/console && zip ../../export/console.zip bootstrap breftoolbox.php
-
-# Build Docker images
-docker-images:
-	# Build the base environment (without PHP)
-	cd base ; docker build --file base.Dockerfile -t bref/tmp/step-1/build-environment .
-	# Build the `bref/build-php-XX` images
-	# (build only the first `FROM` section of the Dockerfile)
-	cd base ; docker build --file php-73.Dockerfile -t bref/build-php-73 --target build-environment .
-	cd base ; docker build --file php-74.Dockerfile -t bref/build-php-74 --target build-environment .
-	cd base ; docker build --file php-80.Dockerfile -t bref/build-php-80 --target build-environment .
-	cd base ; docker build --file php-81.Dockerfile -t bref/build-php-81 --target build-environment .
-	cd base ; docker build --file php-82.Dockerfile -t bref/build-php-82 --target build-environment .
-	# Build the whole Dockerfile to generate the cleaned images that will be used in the next step
-	cd base ; docker build --file php-73.Dockerfile -t bref/tmp/cleaned-build-php-73 .
-	cd base ; docker build --file php-74.Dockerfile -t bref/tmp/cleaned-build-php-74 .
-	cd base ; docker build --file php-80.Dockerfile -t bref/tmp/cleaned-build-php-80 .
-	cd base ; docker build --file php-81.Dockerfile -t bref/tmp/cleaned-build-php-81 .
-	cd base ; docker build --file php-82.Dockerfile -t bref/tmp/cleaned-build-php-82 .
-	# - function
-	cd layers/function ; docker build -t bref/php-73 --build-arg PHP_VERSION=73 .
-	cd layers/function ; docker build -t bref/php-74 --build-arg PHP_VERSION=74 .
-	cd layers/function ; docker build -t bref/php-80 --build-arg PHP_VERSION=80 .
-	cd layers/function ; docker build -t bref/php-81 --build-arg PHP_VERSION=81 .
-	cd layers/function ; docker build -t bref/php-82 --build-arg PHP_VERSION=82 .
-	# - fpm
-	cd layers/fpm ; docker build -t bref/php-73-fpm --build-arg PHP_VERSION=73 .
-	cd layers/fpm ; docker build -t bref/php-74-fpm --build-arg PHP_VERSION=74 .
-	cd layers/fpm ; docker build -t bref/php-80-fpm --build-arg PHP_VERSION=80 .
-	cd layers/fpm ; docker build -t bref/php-81-fpm --build-arg PHP_VERSION=81 .
-	cd layers/fpm ; docker build -t bref/php-82-fpm --build-arg PHP_VERSION=82 .
-	# - console
-	cd layers/console ; docker build -t bref/php-73-console --build-arg PHP_VERSION=73 .
-	cd layers/console ; docker build -t bref/php-74-console --build-arg PHP_VERSION=74 .
-	cd layers/console ; docker build -t bref/php-80-console --build-arg PHP_VERSION=80 .
-	cd layers/console ; docker build -t bref/php-81-console --build-arg PHP_VERSION=81 .
-	cd layers/console ; docker build -t bref/php-82-console --build-arg PHP_VERSION=82 .
-	# Other Docker images
-	cd layers/fpm-dev ; docker build -t bref/php-73-fpm-dev --build-arg PHP_VERSION=73 .
-	cd layers/fpm-dev ; docker build -t bref/php-74-fpm-dev --build-arg PHP_VERSION=74 .
-	cd layers/fpm-dev ; docker build -t bref/php-80-fpm-dev --build-arg PHP_VERSION=80 .
-	cd layers/fpm-dev ; docker build -t bref/php-81-fpm-dev --build-arg PHP_VERSION=81 .
-	cd layers/fpm-dev ; docker build -t bref/php-82-fpm-dev --build-arg PHP_VERSION=82 .
-	cd layers/web; docker build -t bref/fpm-dev-gateway .
-	# Run tests
-	php layers/tests.php
diff --git a/runtime/README.md b/runtime/README.md
index f8126358b..76292dd08 100644
--- a/runtime/README.md
+++ b/runtime/README.md
@@ -1,41 +1,1 @@
-# Bref runtimes and Docker images
-
-Multiple Docker images are created:
-
-- `bref/tmp/...` images are created locally and not published online
-- `bref/...` images are published on Docker Hub
-
-Goal for the Docker images:
-
-- `bref/tmp/step-1/build-environment`
-  
-    This image contains everything needed to compile PHP. This image is created standalone because
-    it is common for each PHP version. The next step involves a different Dockerfile per PHP version.
-
-- `bref/build-php-XX`
-  
-    There is one image per PHP version. These images contain PHP compiled with all its extensions.
-    It is published so that anyone can use it to compile their own extensions.
-    It is not the final image because it contains too many things that need to be removed.
-
-- `bref/tmp/cleaned-build-php-XX`
-  
-    There is one image per PHP version. These images contain PHP compiled with all its extensions,
-    but with all extra files removed. These images do not contain the bootstrap files and PHP config files.
-
-- `bref/php-XX`, `bref/php-XX-fpm`
-  
-    There is one image per PHP version. These images contain exactly what will be in the layers in `/opt`.
-
-    These images have 3 goals:
-  
-    - export `/opt` to create layers
-    - run applications locally using Docker: this requires overloading the entrypoint if necessary
-    - deploy applications on Lambda using Docker images: set the handler as `cmd`
-
-- `bref/php-XX-fpm-dev`
-
-    Used to run web applications locally.
-
-Layers are created in the `export/` directory (we zip the `/opt` directory of each Docker image).
-We upload and publish the layers in every AWS region using the zip files.
+The code that builds the AWS Lambda runtimes for PHP is in this repository: https://github.com/brefphp/aws-lambda-layers
diff --git a/runtime/base/README.md b/runtime/base/README.md
deleted file mode 100644
index d6e4ca366..000000000
--- a/runtime/base/README.md
+++ /dev/null
@@ -1,1 +0,0 @@
-There is one Dockerfile per minor PHP version because each version can have slightly different configuration options.
diff --git a/runtime/base/base.Dockerfile b/runtime/base/base.Dockerfile
deleted file mode 100644
index 3d36a304f..000000000
--- a/runtime/base/base.Dockerfile
+++ /dev/null
@@ -1,424 +0,0 @@
-# The container we build here contains everything needed to compile PHP.
-
-
-# Lambda instances use a custom AMI named Amazon Linux 2, as
-# documented under the AWS Lambda Runtimes.
-# https://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html
-# AWS provides it a Docker image that we use here:
-# https://github.com/amazonlinux/container-images/tree/amzn2
-FROM public.ecr.aws/lambda/provided:al2
-
-
-# Move to /tmp to compile everything in there.
-WORKDIR /tmp
-
-
-# Lambda is based on Amazon Linux 2. Lock YUM to that release version.
-RUN sed -i 's/releasever=latest/releaserver=amzn2/' /etc/yum.conf
-
-
-RUN set -xe \
-    # Download yum repository data to cache
- && yum makecache \
-    # Default Development Tools
- && yum groupinstall -y "Development Tools" --setopt=group_package_types=mandatory,default
-
-
-# The default version of cmake we can get from the yum repo is 2.8.12. We need cmake to build a few of
-# our libraries, and at least one library requires a version of cmake greater than that.
-#
-# Needed to build:
-# - libzip: minimum required CMAKE version 3.0.
-RUN LD_LIBRARY_PATH= yum install -y cmake3
-RUN ln -s /usr/bin/cmake3 /usr/bin/cmake
-
-# Use the bash shell, instead of /bin/sh
-# Why? We need to document this.
-SHELL ["/bin/bash", "-c"]
-
-# We need a base path for all the sourcecode we will build from.
-ENV BUILD_DIR="/tmp/build"
-
-# We need a base path for the builds to install to. This path must
-# match the path that bref will be unpackaged to in Lambda.
-ENV INSTALL_DIR="/opt/bref"
-
-# Apply stack smash protection to functions using local buffers and alloca()
-# ## # Enable size optimization (-Os)
-# # Enable linker optimization (this sorts the hash buckets to improve cache locality, and is non-default)
-# # Adds GNU HASH segments to generated executables (this is used if present, and is much faster than sysv hash; in this configuration, sysv hash is also generated)
-
-# We need some default compiler variables setup
-ENV PKG_CONFIG_PATH="${INSTALL_DIR}/lib64/pkgconfig:${INSTALL_DIR}/lib/pkgconfig" \
-    PKG_CONFIG="/usr/bin/pkg-config" \
-    PATH="${INSTALL_DIR}/bin:${PATH}"
-
-
-ENV LD_LIBRARY_PATH="${INSTALL_DIR}/lib64:${INSTALL_DIR}/lib"
-
-# Enable parallelism for cmake (like make -j)
-# See https://stackoverflow.com/a/50883540/245552
-RUN export CMAKE_BUILD_PARALLEL_LEVEL=$(nproc)
-
-# Ensure we have all the directories we require in the container.
-RUN mkdir -p ${BUILD_DIR}  \
-    ${INSTALL_DIR}/bin \
-    ${INSTALL_DIR}/doc \
-    ${INSTALL_DIR}/etc/php \
-    ${INSTALL_DIR}/etc/php/conf.d \
-    ${INSTALL_DIR}/include \
-    ${INSTALL_DIR}/lib \
-    ${INSTALL_DIR}/lib64 \
-    ${INSTALL_DIR}/libexec \
-    ${INSTALL_DIR}/sbin \
-    ${INSTALL_DIR}/share
-
-
-###############################################################################
-# ZLIB Build
-# https://github.com/madler/zlib/releases
-# Needed for:
-#   - openssl
-#   - curl
-#   - php
-# Used By:
-#   - xml2
-ENV VERSION_ZLIB=1.2.13
-ENV ZLIB_BUILD_DIR=${BUILD_DIR}/xml2
-
-RUN set -xe; \
-    mkdir -p ${ZLIB_BUILD_DIR}; \
-# Download and upack the source code
-    curl -Ls  http://zlib.net/zlib-${VERSION_ZLIB}.tar.xz \
-  | tar xJC ${ZLIB_BUILD_DIR} --strip-components=1
-
-# Move into the unpackaged code directory
-WORKDIR  ${ZLIB_BUILD_DIR}/
-
-# Configure the build
-RUN set -xe; \
-    make distclean \
- && CFLAGS="" \
-    CPPFLAGS="-I${INSTALL_DIR}/include  -I/usr/include" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib" \
-    ./configure \
-    --prefix=${INSTALL_DIR} \
-    --64
-
-RUN set -xe; \
-    make install \
- && rm ${INSTALL_DIR}/lib/libz.a
-
-###############################################################################
-# OPENSSL Build
-# https://github.com/openssl/openssl/releases
-# Needs:
-#   - zlib
-# Needed by:
-#   - curl
-#   - php
-ENV VERSION_OPENSSL=1.1.1t
-ENV OPENSSL_BUILD_DIR=${BUILD_DIR}/openssl
-ENV CA_BUNDLE_SOURCE="https://curl.se/ca/cacert.pem"
-ENV CA_BUNDLE="${INSTALL_DIR}/ssl/cert.pem"
-
-
-RUN set -xe; \
-    mkdir -p ${OPENSSL_BUILD_DIR}; \
-# Download and upack the source code
-    curl -Ls  https://github.com/openssl/openssl/archive/OpenSSL_${VERSION_OPENSSL//./_}.tar.gz \
-  | tar xzC ${OPENSSL_BUILD_DIR} --strip-components=1
-
-# Move into the unpackaged code directory
-WORKDIR  ${OPENSSL_BUILD_DIR}/
-
-
-# Configure the build
-RUN set -xe; \
-    CFLAGS="" \
-    CPPFLAGS="-I${INSTALL_DIR}/include  -I/usr/include" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib" \
-    ./config \
-        --prefix=${INSTALL_DIR} \
-        --openssldir=${INSTALL_DIR}/ssl \
-        --release \
-        no-tests \
-        shared \
-        zlib
-
-RUN set -xe; \
-    make install \
- && curl -Lk -o ${CA_BUNDLE} ${CA_BUNDLE_SOURCE}
-
-###############################################################################
-# LIBSSH2 Build
-# https://github.com/libssh2/libssh2/releases
-# Needs:
-#   - zlib
-#   - OpenSSL
-# Needed by:
-#   - curl
-ENV VERSION_LIBSSH2=1.10.0
-ENV LIBSSH2_BUILD_DIR=${BUILD_DIR}/libssh2
-
-RUN set -xe; \
-    mkdir -p ${LIBSSH2_BUILD_DIR}/bin; \
-    # Download and upack the source code
-    curl -Ls https://github.com/libssh2/libssh2/releases/download/libssh2-${VERSION_LIBSSH2}/libssh2-${VERSION_LIBSSH2}.tar.gz \
-  | tar xzC ${LIBSSH2_BUILD_DIR} --strip-components=1
-
-# Move into the unpackaged code directory
-WORKDIR  ${LIBSSH2_BUILD_DIR}/bin/
-
-# Configure the build
-RUN set -xe; \
-    CFLAGS="" \
-    CPPFLAGS="-I${INSTALL_DIR}/include  -I/usr/include" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib" \
-    cmake .. \
-    -DBUILD_SHARED_LIBS=ON \
-    -DCRYPTO_BACKEND=OpenSSL \
-    -DENABLE_ZLIB_COMPRESSION=ON \
-    -DCMAKE_INSTALL_PREFIX=${INSTALL_DIR} \
-    -DCMAKE_BUILD_TYPE=RELEASE
-
-RUN set -xe; \
-    cmake  --build . --target install
-
-###############################################################################
-# LIBNGHTTP2 Build
-# This adds support for HTTP 2 requests in curl.
-# See https://github.com/brefphp/bref/issues/727 and https://github.com/brefphp/bref/pull/740
-# https://github.com/nghttp2/nghttp2/releases
-# Needs:
-#   - zlib
-#   - OpenSSL
-# Needed by:
-#   - curl
-ENV VERSION_NGHTTP2=1.51.0
-ENV NGHTTP2_BUILD_DIR=${BUILD_DIR}/nghttp2
-
-RUN set -xe; \
-    mkdir -p ${NGHTTP2_BUILD_DIR}; \
-    curl -Ls https://github.com/nghttp2/nghttp2/releases/download/v${VERSION_NGHTTP2}/nghttp2-${VERSION_NGHTTP2}.tar.gz \
-    | tar xzC ${NGHTTP2_BUILD_DIR} --strip-components=1
-
-WORKDIR  ${NGHTTP2_BUILD_DIR}/
-
-RUN set -xe; \
-    CFLAGS="" \
-    CPPFLAGS="-I${INSTALL_DIR}/include  -I/usr/include" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib" \
-    ./configure \
-    --enable-lib-only \
-    --prefix=${INSTALL_DIR}
-
-RUN set -xe; \
-    make install
-
-
-###############################################################################
-# CURL Build
-# # https://github.com/curl/curl/releases
-# # Needs:
-# #   - zlib
-# #   - OpenSSL
-# #   - libssh2
-# # Needed by:
-# #   - php
-ENV VERSION_CURL=7.85.0
-ENV CURL_BUILD_DIR=${BUILD_DIR}/curl
-
-RUN set -xe; \
-            mkdir -p ${CURL_BUILD_DIR}/bin; \
-curl -Ls https://github.com/curl/curl/archive/curl-${VERSION_CURL//./_}.tar.gz \
-| tar xzC ${CURL_BUILD_DIR} --strip-components=1
-
-
-WORKDIR  ${CURL_BUILD_DIR}/
-
-RUN set -xe; \
-    ./buildconf \
- && CFLAGS="" \
-    CPPFLAGS="-I${INSTALL_DIR}/include  -I/usr/include" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib" \
-    ./configure \
-    --prefix=${INSTALL_DIR} \
-    --with-ca-bundle=${CA_BUNDLE} \
-    --enable-shared \
-    --disable-static \
-    --enable-optimize \
-    --disable-warnings \
-    --disable-dependency-tracking \
-    --with-zlib \
-    --enable-http \
-    --enable-ftp  \
-    --enable-file \
-    --enable-proxy  \
-    --enable-tftp \
-    --enable-ipv6 \
-    --enable-openssl-auto-load-config \
-    --enable-cookies \
-    --with-gnu-ld \
-    --with-ssl \
-    --with-libssh2 \
-    --with-nghttp2
-
-
-RUN set -xe; \
-    make install
-
-###############################################################################
-# LIBXML2 Build
-# https://github.com/GNOME/libxml2/releases
-# Uses:
-#   - zlib
-# Needed by:
-#   - php
-ENV VERSION_XML2=2.10.3
-ENV XML2_BUILD_DIR=${BUILD_DIR}/xml2
-
-RUN set -xe; \
-    mkdir -p ${XML2_BUILD_DIR}; \
-# Download and upack the source code
-    curl -Ls https://download.gnome.org/sources/libxml2/${VERSION_XML2%.*}/libxml2-${VERSION_XML2}.tar.xz \
-  | tar xJC ${XML2_BUILD_DIR} --strip-components=1
-
-# Move into the unpackaged code directory
-WORKDIR  ${XML2_BUILD_DIR}/
-
-# Configure the build
-RUN set -xe; \
-    CFLAGS="" \
-    CPPFLAGS="-I${INSTALL_DIR}/include  -I/usr/include" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib" \
-    ./configure \
-    --prefix=${INSTALL_DIR} \
-    --with-sysroot=${INSTALL_DIR} \
-    --enable-shared \
-    --disable-static \
-    --with-html \
-    --with-history \
-    --enable-ipv6=no \
-    --with-icu \
-    --with-zlib=${INSTALL_DIR} \
-    --without-python
-
-RUN set -xe; \
-    make install \
- && cp xml2-config ${INSTALL_DIR}/bin/xml2-config
-
-###############################################################################
-# LIBZIP Build
-# https://github.com/nih-at/libzip/releases
-# Needed by:
-#   - php
-ENV VERSION_ZIP=1.9.2
-ENV ZIP_BUILD_DIR=${BUILD_DIR}/zip
-
-RUN set -xe; \
-    mkdir -p ${ZIP_BUILD_DIR}/bin/; \
-# Download and upack the source code
-    curl -Ls https://github.com/nih-at/libzip/releases/download/v${VERSION_ZIP}/libzip-${VERSION_ZIP}.tar.gz \
-  | tar xzC ${ZIP_BUILD_DIR} --strip-components=1
-
-# Move into the unpackaged code directory
-WORKDIR  ${ZIP_BUILD_DIR}/bin/
-
-# Configure the build
-RUN set -xe; \
-    CFLAGS="" \
-    CPPFLAGS="-I${INSTALL_DIR}/include  -I/usr/include" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib" \
-    cmake .. \
-    -DCMAKE_INSTALL_PREFIX=${INSTALL_DIR} \
-    -DCMAKE_BUILD_TYPE=RELEASE
-
-RUN set -xe; \
-    cmake  --build . --target install
-
-###############################################################################
-# LIBSODIUM Build
-# https://github.com/jedisct1/libsodium/releases
-# Needs:
-#
-# Needed by:
-#   - php
-ENV VERSION_LIBSODIUM=1.0.18
-ENV LIBSODIUM_BUILD_DIR=${BUILD_DIR}/libsodium
-
-RUN set -xe; \
-    mkdir -p ${LIBSODIUM_BUILD_DIR}; \
-   # Download and unpack the source code
-    curl -Ls https://github.com/jedisct1/libsodium/archive/${VERSION_LIBSODIUM}.tar.gz \
-  | tar xzC ${LIBSODIUM_BUILD_DIR} --strip-components=1
-
-# Move into the unpackaged code directory
-WORKDIR  ${LIBSODIUM_BUILD_DIR}/
-
-# Configure the build
-RUN set -xe; \
-    CFLAGS="" \
-    CPPFLAGS="-I${INSTALL_DIR}/include  -I/usr/include" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib" \
-    ./autogen.sh \
-&& ./configure --prefix=${INSTALL_DIR}
-
-RUN set -xe; \
-    make install
-
-###############################################################################
-# Postgres Build
-# https://github.com/postgres/postgres/releases
-# Needs:
-#   - OpenSSL
-# Needed by:
-#   - php
-ENV VERSION_POSTGRES=15.2
-ENV POSTGRES_BUILD_DIR=${BUILD_DIR}/postgres
-
-RUN set -xe; \
-    mkdir -p ${POSTGRES_BUILD_DIR}/bin; \
-    curl -Ls https://github.com/postgres/postgres/archive/REL_${VERSION_POSTGRES//./_}.tar.gz \
-    | tar xzC ${POSTGRES_BUILD_DIR} --strip-components=1
-
-
-WORKDIR  ${POSTGRES_BUILD_DIR}/
-
-RUN set -xe; \
-    CFLAGS="" \
-    CPPFLAGS="-I${INSTALL_DIR}/include  -I/usr/include" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib" \
-    ./configure --prefix=${INSTALL_DIR} --with-openssl --without-readline
-
-RUN set -xe; cd ${POSTGRES_BUILD_DIR}/src/interfaces/libpq && make -j $(nproc) && make install
-RUN set -xe; cd ${POSTGRES_BUILD_DIR}/src/bin/pg_config && make -j $(nproc) && make install
-RUN set -xe; cd ${POSTGRES_BUILD_DIR}/src/backend && make generated-headers
-RUN set -xe; cd ${POSTGRES_BUILD_DIR}/src/include && make install
-
-# Install some dev files for using old libraries already on the system
-# readline-devel : needed for the --with-libedit flag
-# gettext-devel : needed for the --with-gettext flag
-# libicu-devel : needed for
-# libxslt-devel : needed for the XSL extension
-# sqlite-devel : Since PHP 7.4 this must be installed (https://github.com/php/php-src/blob/99b8e67615159fc600a615e1e97f2d1cf18f14cb/UPGRADING#L616-L619)
-RUN LD_LIBRARY_PATH= yum install -y readline-devel gettext-devel libicu-devel libxslt-devel sqlite-devel
-
-RUN cp -a /usr/lib64/libgcrypt.so* ${INSTALL_DIR}/lib64/
-
-# Copy readline shared libs that are not present in amazonlinux2
-RUN cp -a /usr/lib64/libreadline.so?* ${INSTALL_DIR}/lib64/
-
-# Copy gpg-error shared libds that are not present in amazonlinux2
-RUN cp -a /usr/lib64/libgpg-error.so* ${INSTALL_DIR}/lib64/
-
-# Copy gettext shared libs that are not present in amazonlinux2
-RUN cp -a /usr/lib64/libasprintf.so* ${INSTALL_DIR}/lib64/
-RUN cp -a /usr/lib64/libgettextpo.so* ${INSTALL_DIR}/lib64/
-RUN cp -a /usr/lib64/preloadable_libintl.so* ${INSTALL_DIR}/lib64/
-
-# Copy xslt shared libs that are not present in amazonlinux2
-RUN cp -a /usr/lib64/lib*xslt*.so* ${INSTALL_DIR}/lib64/
-
-# Copy sqlite3 shared libs that are not present in amazonlinux2
-RUN cp -a /usr/lib64/libsqlite3*.so* ${INSTALL_DIR}/lib64/
diff --git a/runtime/base/clean.sh b/runtime/base/clean.sh
deleted file mode 100755
index 52f9b7596..000000000
--- a/runtime/base/clean.sh
+++ /dev/null
@@ -1,45 +0,0 @@
-# ------------------------------------------
-# This script cleans extra files from /opt
-# to keep the layers as small as possible.
-# ------------------------------------------
-
-# Stop on error
-set -e
-# Treat unset variables and parameters as an error.
-set -u
-
-# Strip all the unneeded symbols from shared libraries to reduce size.
-find /opt/bref -type f -name "*.so*" -exec strip --strip-unneeded {} \;
-find /opt/bref -type f -name "*.a"|xargs rm
-find /opt/bref -type f -name "*.la"|xargs rm
-find /opt/bref -type f -name "*.dist"|xargs rm
-find /opt/bref -type f -executable -exec sh -c "file -i '{}' | grep -q 'x-executable; charset=binary'" \; -print|xargs strip --strip-all
-
-# Cleanup all the binaries we don't want.
-find /opt/bref/bin -mindepth 1 -maxdepth 1 ! -name "php" ! -name "php-fpm" -exec rm {} \+
-find /opt/bin -mindepth 1 -maxdepth 1 ! -name "php" ! -name "php-fpm" -exec rm {} \+
-
-# Cleanup all the files we don't want either
-# We do not support running pear functions in Lambda
-rm -rf /opt/bref/lib/php/PEAR
-rm -rf /opt/bref/share
-rm -rf /opt/bref/include
-rm -rf /opt/bref/php
-rm -rf /opt/bref/{lib,lib64}/pkgconfig
-rm -rf /opt/bref/{lib,lib64}/cmake
-rm -rf /opt/bref/lib/xml2Conf.sh
-find /opt/bref/lib/php -mindepth 1 -maxdepth 1 -type d -a -not -name extensions |xargs rm -rf
-find /opt/bref/lib/php -mindepth 1 -maxdepth 1 -type f |xargs rm -rf
-rm -rf /opt/bref/lib/php/test
-rm -rf /opt/bref/lib/php/doc
-rm -rf /opt/bref/lib/php/docs
-rm -rf /opt/bref/tests
-rm -rf /opt/bref/doc
-rm -rf /opt/bref/docs
-rm -rf /opt/bref/man
-rm -rf /opt/bref/php/man
-rm -rf /opt/bref/www
-rm -rf /opt/bref/cfg
-rm -rf /opt/bref/libexec
-rm -rf /opt/bref/var
-rm -rf /opt/bref/data
diff --git a/runtime/base/php-73.Dockerfile b/runtime/base/php-73.Dockerfile
deleted file mode 100644
index 31f94be33..000000000
--- a/runtime/base/php-73.Dockerfile
+++ /dev/null
@@ -1,116 +0,0 @@
-# The container we build here contains everything needed to compile PHP + PHP.
-#
-# It can be used as a base to compile extra extensions.
-
-# PHP Build
-# https://github.com/php/php-src/releases
-# Needs:
-#   - zlib
-#   - libxml2
-#   - openssl
-#   - readline
-#   - sodium
-
-FROM bref/tmp/step-1/build-environment as build-environment
-
-ENV VERSION_PHP=7.3.33
-
-ENV PHP_BUILD_DIR=${BUILD_DIR}/php
-RUN set -xe; \
-    mkdir -p ${PHP_BUILD_DIR}; \
-    # Download and upack the source code
-    # --location will follow redirects
-    # --silent will hide the progress, but also the errors: we restore error messages with --show-error
-    # --fail makes sure that curl returns an error instead of fetching the 404 page
-    curl --location --silent --show-error --fail https://github.com/php/php-src/archive/php-${VERSION_PHP}.tar.gz \
-  | tar xzC ${PHP_BUILD_DIR} --strip-components=1
-# Move into the unpackaged code directory
-WORKDIR  ${PHP_BUILD_DIR}/
-
-# Configure the build
-# -fstack-protector-strong : Be paranoid about stack overflows
-# -fpic : Make PHP's main executable position-independent (improves ASLR security mechanism, and has no performance impact on x86_64)
-# -fpie : Support Address Space Layout Randomization (see -fpic)
-# -O3 : Optimize for fastest binaries possible.
-# -I : Add the path to the list of directories to be searched for header files during preprocessing.
-# --enable-option-checking=fatal: make sure invalid --configure-flags are fatal errors instead of just warnings
-# --enable-ftp: because ftp_ssl_connect() needs ftp to be compiled statically (see https://github.com/docker-library/php/issues/236)
-# --enable-mbstring: because otherwise there's no way to get pecl to use it properly (see https://github.com/docker-library/php/issues/195)
-# --with-zlib and --with-zlib-dir: See https://stackoverflow.com/a/42978649/245552
-# --enable-opcache-file: allows to use the `opcache.file_cache` option
-#
-RUN set -xe \
- && ./buildconf --force \
- && CFLAGS="-fstack-protector-strong -fpic -fpie -O3 -I${INSTALL_DIR}/include -I/usr/include -ffunction-sections -fdata-sections" \
-    CPPFLAGS="-fstack-protector-strong -fpic -fpie -O3 -I${INSTALL_DIR}/include -I/usr/include -ffunction-sections -fdata-sections" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib -Wl,-O1 -Wl,--strip-all -Wl,--hash-style=both -pie" \
-    ./configure \
-        --build=x86_64-pc-linux-gnu \
-        --prefix=${INSTALL_DIR} \
-        --enable-option-checking=fatal \
-        --enable-sockets \
-        --with-config-file-path=${INSTALL_DIR}/etc/php \
-        --with-config-file-scan-dir=${INSTALL_DIR}/etc/php/conf.d:/var/task/php/conf.d \
-        --enable-fpm \
-        --disable-cgi \
-        --enable-cli \
-        --disable-phpdbg \
-        --disable-phpdbg-webhelper \
-        --with-sodium \
-        --with-readline \
-        --with-openssl \
-        --with-zlib=${INSTALL_DIR} \
-        --with-zlib-dir=${INSTALL_DIR} \
-        --with-curl \
-        --enable-exif \
-        --enable-ftp \
-        --with-gettext \
-        --enable-mbstring \
-        --with-pdo-mysql=shared,mysqlnd \
-        --with-mysqli \
-        --enable-pcntl \
-        --enable-zip \
-        --enable-bcmath \
-        --with-pdo-pgsql=shared,${INSTALL_DIR} \
-        --enable-intl=shared \
-        --enable-opcache-file \
-        --enable-soap \
-        --with-xsl=${INSTALL_DIR}
-RUN make -j $(nproc)
-# Run `make install` and override PEAR's PHAR URL because pear.php.net is down
-RUN set -xe; \
- make install PEAR_INSTALLER_URL='https://github.com/pear/pearweb_phars/raw/master/install-pear-nozlib.phar'; \
- { find ${INSTALL_DIR}/bin ${INSTALL_DIR}/sbin -type f -perm +0111 -exec strip --strip-all '{}' + || true; }; \
- make clean; \
- cp php.ini-production ${INSTALL_DIR}/etc/php/php.ini
-
-# Symlink all our binaries into /opt/bin so that Lambda sees them in the path.
-RUN mkdir -p /opt/bin \
-  && cd /opt/bin \
-  && ln -s ../bref/bin/* . \
-  && ln -s ../bref/sbin/* .
-
-# Install extensions
-# We can install extensions manually or using `pecl`
-RUN pecl install APCu
-
-# Run the next step in the previous environment because the `clean.sh` script needs `find`,
-# which isn't installed by default
-FROM build-environment as build-environment-cleaned
-# Remove extra files to make the layers as slim as possible
-COPY clean.sh /tmp/clean.sh
-RUN /tmp/clean.sh && rm /tmp/clean.sh
-
-
-# Now we start back from a clean image.
-# We get rid of everything that is unnecessary (build tools, source code, and anything else
-# that might have created intermediate layers for docker) by copying online the /opt directory.
-FROM public.ecr.aws/lambda/provided:al2
-ENV PATH="/opt/bin:${PATH}" \
-    LD_LIBRARY_PATH="/opt/bref/lib64:/opt/bref/lib"
-
-# Copy everything we built above into the same dir on the base AmazonLinux container.
-COPY --from=build-environment-cleaned /opt /opt
-
-# Set the workdir to the same directory as in AWS Lambda
-WORKDIR /var/task
diff --git a/runtime/base/php-74.Dockerfile b/runtime/base/php-74.Dockerfile
deleted file mode 100644
index b577f1593..000000000
--- a/runtime/base/php-74.Dockerfile
+++ /dev/null
@@ -1,140 +0,0 @@
-# The container we build here contains everything needed to compile PHP + PHP.
-#
-# It can be used as a base to compile extra extensions.
-
-# PHP Build
-# https://github.com/php/php-src/releases
-# Needs:
-#   - zlib
-#   - libxml2
-#   - openssl
-#   - readline
-#   - sodium
-
-FROM bref/tmp/step-1/build-environment as build-environment
-
-
-###############################################################################
-# Oniguruma
-# This library is not packaged in PHP since PHP 7.4.
-# See https://github.com/php/php-src/blob/43dc7da8e3719d3e89bd8ec15ebb13f997bbbaa9/UPGRADING#L578-L581
-# We do not install the system version because I didn't manage to make it work...
-# Ideally we shouldn't compile it ourselves.
-# https://github.com/kkos/oniguruma/releases
-# Needed by:
-#   - php mbstring
-ENV VERSION_ONIG=6.9.8
-ENV ONIG_BUILD_DIR=${BUILD_DIR}/oniguruma
-RUN set -xe; \
-    mkdir -p ${ONIG_BUILD_DIR}; \
-    curl -Ls https://github.com/kkos/oniguruma/releases/download/v${VERSION_ONIG}/onig-${VERSION_ONIG}.tar.gz \
-    | tar xzC ${ONIG_BUILD_DIR} --strip-components=1
-WORKDIR  ${ONIG_BUILD_DIR}/
-RUN set -xe; \
-    ./configure --prefix=${INSTALL_DIR}; \
-    make -j $(nproc); \
-    make install
-
-
-ENV VERSION_PHP=7.4.33
-
-
-ENV PHP_BUILD_DIR=${BUILD_DIR}/php
-RUN set -xe; \
-    mkdir -p ${PHP_BUILD_DIR}; \
-    # Download and upack the source code
-    # --location will follow redirects
-    # --silent will hide the progress, but also the errors: we restore error messages with --show-error
-    # --fail makes sure that curl returns an error instead of fetching the 404 page
-    curl --location --silent --show-error --fail https://www.php.net/get/php-${VERSION_PHP}.tar.gz/from/this/mirror \
-  | tar xzC ${PHP_BUILD_DIR} --strip-components=1
-# Move into the unpackaged code directory
-WORKDIR  ${PHP_BUILD_DIR}/
-
-# Configure the build
-# -fstack-protector-strong : Be paranoid about stack overflows
-# -fpic : Make PHP's main executable position-independent (improves ASLR security mechanism, and has no performance impact on x86_64)
-# -fpie : Support Address Space Layout Randomization (see -fpic)
-# -O3 : Optimize for fastest binaries possible.
-# -I : Add the path to the list of directories to be searched for header files during preprocessing.
-# --enable-option-checking=fatal: make sure invalid --configure-flags are fatal errors instead of just warnings
-# --enable-ftp: because ftp_ssl_connect() needs ftp to be compiled statically (see https://github.com/docker-library/php/issues/236)
-# --enable-mbstring: because otherwise there's no way to get pecl to use it properly (see https://github.com/docker-library/php/issues/195)
-# --with-zlib and --with-zlib-dir: See https://stackoverflow.com/a/42978649/245552
-# --with-pear: necessary for `pecl` to work (to install PHP extensions)
-#
-RUN set -xe \
- && ./buildconf --force \
- && CFLAGS="-fstack-protector-strong -fpic -fpie -O3 -I${INSTALL_DIR}/include -I/usr/include -ffunction-sections -fdata-sections" \
-    CPPFLAGS="-fstack-protector-strong -fpic -fpie -O3 -I${INSTALL_DIR}/include -I/usr/include -ffunction-sections -fdata-sections" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib -Wl,-O1 -Wl,--strip-all -Wl,--hash-style=both -pie" \
-    ./configure \
-        --build=x86_64-pc-linux-gnu \
-        --prefix=${INSTALL_DIR} \
-        --enable-option-checking=fatal \
-        --enable-sockets \
-        --with-config-file-path=${INSTALL_DIR}/etc/php \
-        --with-config-file-scan-dir=${INSTALL_DIR}/etc/php/conf.d:/var/task/php/conf.d \
-        --enable-fpm \
-        --disable-cgi \
-        --enable-cli \
-        --disable-phpdbg \
-        --disable-phpdbg-webhelper \
-        --with-sodium \
-        --with-readline \
-        --with-openssl \
-        --with-zlib=${INSTALL_DIR} \
-        --with-zlib-dir=${INSTALL_DIR} \
-        --with-curl \
-        --enable-exif \
-        --enable-ftp \
-        --with-gettext \
-        --enable-mbstring \
-        --with-pdo-mysql=shared,mysqlnd \
-        --with-mysqli \
-        --enable-pcntl \
-        --with-zip \
-        --enable-bcmath \
-        --with-pdo-pgsql=shared,${INSTALL_DIR} \
-        --enable-intl=shared \
-        --enable-soap \
-        --with-xsl=${INSTALL_DIR} \
-        --with-pear
-RUN make -j $(nproc)
-# Run `make install` and override PEAR's PHAR URL because pear.php.net is down
-RUN set -xe; \
- make install PEAR_INSTALLER_URL='https://github.com/pear/pearweb_phars/raw/master/install-pear-nozlib.phar'; \
- { find ${INSTALL_DIR}/bin ${INSTALL_DIR}/sbin -type f -perm +0111 -exec strip --strip-all '{}' + || true; }; \
- make clean; \
- cp php.ini-production ${INSTALL_DIR}/etc/php/php.ini
-
-# Symlink all our binaries into /opt/bin so that Lambda sees them in the path.
-RUN mkdir -p /opt/bin \
-    && cd /opt/bin \
-    && ln -s ../bref/bin/* . \
-    && ln -s ../bref/sbin/* .
-
-# Install extensions
-# We can install extensions manually or using `pecl`
-RUN pecl install APCu
-
-# Run the next step in the previous environment because the `clean.sh` script needs `find`,
-# which isn't installed by default
-FROM build-environment as build-environment-cleaned
-# Remove extra files to make the layers as slim as possible
-COPY clean.sh /tmp/clean.sh
-RUN /tmp/clean.sh && rm /tmp/clean.sh
-
-
-# Now we start back from a clean image.
-# We get rid of everything that is unnecessary (build tools, source code, and anything else
-# that might have created intermediate layers for docker) by copying online the /opt directory.
-FROM public.ecr.aws/lambda/provided:al2
-ENV PATH="/opt/bin:${PATH}" \
-    LD_LIBRARY_PATH="/opt/bref/lib64:/opt/bref/lib"
-
-# Copy everything we built above into the same dir on the base AmazonLinux container.
-COPY --from=build-environment-cleaned /opt /opt
-
-# Set the workdir to the same directory as in AWS Lambda
-WORKDIR /var/task
diff --git a/runtime/base/php-80.Dockerfile b/runtime/base/php-80.Dockerfile
deleted file mode 100644
index 354626f44..000000000
--- a/runtime/base/php-80.Dockerfile
+++ /dev/null
@@ -1,140 +0,0 @@
-# The container we build here contains everything needed to compile PHP + PHP.
-#
-# It can be used as a base to compile extra extensions.
-
-# PHP Build
-# https://github.com/php/php-src/releases
-# Needs:
-#   - zlib
-#   - libxml2
-#   - openssl
-#   - readline
-#   - sodium
-
-FROM bref/tmp/step-1/build-environment as build-environment
-
-
-###############################################################################
-# Oniguruma
-# This library is not packaged in PHP since PHP 7.4.
-# See https://github.com/php/php-src/blob/43dc7da8e3719d3e89bd8ec15ebb13f997bbbaa9/UPGRADING#L578-L581
-# We do not install the system version because I didn't manage to make it work...
-# Ideally we shouldn't compile it ourselves.
-# https://github.com/kkos/oniguruma/releases
-# Needed by:
-#   - php mbstring
-ENV VERSION_ONIG=6.9.8
-ENV ONIG_BUILD_DIR=${BUILD_DIR}/oniguruma
-RUN set -xe; \
-    mkdir -p ${ONIG_BUILD_DIR}; \
-    curl -Ls https://github.com/kkos/oniguruma/releases/download/v${VERSION_ONIG}/onig-${VERSION_ONIG}.tar.gz \
-    | tar xzC ${ONIG_BUILD_DIR} --strip-components=1
-WORKDIR  ${ONIG_BUILD_DIR}/
-RUN set -xe; \
-    ./configure --prefix=${INSTALL_DIR}; \
-    make -j $(nproc); \
-    make install
-
-
-ENV VERSION_PHP=8.0.28
-
-
-ENV PHP_BUILD_DIR=${BUILD_DIR}/php
-RUN set -xe; \
-    mkdir -p ${PHP_BUILD_DIR}; \
-    # Download and upack the source code
-    # --location will follow redirects
-    # --silent will hide the progress, but also the errors: we restore error messages with --show-error
-    # --fail makes sure that curl returns an error instead of fetching the 404 page
-    curl --location --silent --show-error --fail https://www.php.net/get/php-${VERSION_PHP}.tar.gz/from/this/mirror \
-  | tar xzC ${PHP_BUILD_DIR} --strip-components=1
-# Move into the unpackaged code directory
-WORKDIR  ${PHP_BUILD_DIR}/
-
-# Configure the build
-# -fstack-protector-strong : Be paranoid about stack overflows
-# -fpic : Make PHP's main executable position-independent (improves ASLR security mechanism, and has no performance impact on x86_64)
-# -fpie : Support Address Space Layout Randomization (see -fpic)
-# -O3 : Optimize for fastest binaries possible.
-# -I : Add the path to the list of directories to be searched for header files during preprocessing.
-# --enable-option-checking=fatal: make sure invalid --configure-flags are fatal errors instead of just warnings
-# --enable-ftp: because ftp_ssl_connect() needs ftp to be compiled statically (see https://github.com/docker-library/php/issues/236)
-# --enable-mbstring: because otherwise there's no way to get pecl to use it properly (see https://github.com/docker-library/php/issues/195)
-# --with-zlib and --with-zlib-dir: See https://stackoverflow.com/a/42978649/245552
-# --with-pear: necessary for `pecl` to work (to install PHP extensions)
-#
-RUN set -xe \
- && ./buildconf --force \
- && CFLAGS="-fstack-protector-strong -fpic -fpie -O3 -I${INSTALL_DIR}/include -I/usr/include -ffunction-sections -fdata-sections" \
-    CPPFLAGS="-fstack-protector-strong -fpic -fpie -O3 -I${INSTALL_DIR}/include -I/usr/include -ffunction-sections -fdata-sections" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib -Wl,-O1 -Wl,--strip-all -Wl,--hash-style=both -pie" \
-    ./configure \
-        --build=x86_64-pc-linux-gnu \
-        --prefix=${INSTALL_DIR} \
-        --enable-option-checking=fatal \
-        --enable-sockets \
-        --with-config-file-path=${INSTALL_DIR}/etc/php \
-        --with-config-file-scan-dir=${INSTALL_DIR}/etc/php/conf.d:/var/task/php/conf.d \
-        --enable-fpm \
-        --disable-cgi \
-        --enable-cli \
-        --disable-phpdbg \
-        --disable-phpdbg-webhelper \
-        --with-sodium \
-        --with-readline \
-        --with-openssl \
-        --with-zlib=${INSTALL_DIR} \
-        --with-zlib-dir=${INSTALL_DIR} \
-        --with-curl \
-        --enable-exif \
-        --enable-ftp \
-        --with-gettext \
-        --enable-mbstring \
-        --with-pdo-mysql=shared,mysqlnd \
-        --with-mysqli \
-        --enable-pcntl \
-        --with-zip \
-        --enable-bcmath \
-        --with-pdo-pgsql=shared,${INSTALL_DIR} \
-        --enable-intl=shared \
-        --enable-soap \
-        --with-xsl=${INSTALL_DIR} \
-        --with-pear
-RUN make -j $(nproc)
-# Run `make install` and override PEAR's PHAR URL because pear.php.net is down
-RUN set -xe; \
- make install PEAR_INSTALLER_URL='https://github.com/pear/pearweb_phars/raw/master/install-pear-nozlib.phar'; \
- { find ${INSTALL_DIR}/bin ${INSTALL_DIR}/sbin -type f -perm +0111 -exec strip --strip-all '{}' + || true; }; \
- make clean; \
- cp php.ini-production ${INSTALL_DIR}/etc/php/php.ini
-
-# Symlink all our binaries into /opt/bin so that Lambda sees them in the path.
-RUN mkdir -p /opt/bin \
-    && cd /opt/bin \
-    && ln -s ../bref/bin/* . \
-    && ln -s ../bref/sbin/* .
-
-# Install extensions
-# We can install extensions manually or using `pecl`
-RUN pecl install APCu
-
-# Run the next step in the previous environment because the `clean.sh` script needs `find`,
-# which isn't installed by default
-FROM build-environment as build-environment-cleaned
-# Remove extra files to make the layers as slim as possible
-COPY clean.sh /tmp/clean.sh
-RUN /tmp/clean.sh && rm /tmp/clean.sh
-
-
-# Now we start back from a clean image.
-# We get rid of everything that is unnecessary (build tools, source code, and anything else
-# that might have created intermediate layers for docker) by copying online the /opt directory.
-FROM public.ecr.aws/lambda/provided:al2
-ENV PATH="/opt/bin:${PATH}" \
-    LD_LIBRARY_PATH="/opt/bref/lib64:/opt/bref/lib"
-
-# Copy everything we built above into the same dir on the base AmazonLinux container.
-COPY --from=build-environment-cleaned /opt /opt
-
-# Set the workdir to the same directory as in AWS Lambda
-WORKDIR /var/task
diff --git a/runtime/base/php-81.Dockerfile b/runtime/base/php-81.Dockerfile
deleted file mode 100644
index 18ebcfc25..000000000
--- a/runtime/base/php-81.Dockerfile
+++ /dev/null
@@ -1,139 +0,0 @@
-# The container we build here contains everything needed to compile PHP + PHP.
-#
-# It can be used as a base to compile extra extensions.
-
-# PHP Build
-# https://github.com/php/php-src/releases
-# Needs:
-#   - zlib
-#   - libxml2
-#   - openssl
-#   - readline
-#   - sodium
-
-FROM bref/tmp/step-1/build-environment as build-environment
-
-
-###############################################################################
-# Oniguruma
-# This library is not packaged in PHP since PHP 7.4.
-# See https://github.com/php/php-src/blob/43dc7da8e3719d3e89bd8ec15ebb13f997bbbaa9/UPGRADING#L578-L581
-# We do not install the system version because I didn't manage to make it work...
-# Ideally we shouldn't compile it ourselves.
-# https://github.com/kkos/oniguruma/releases
-# Needed by:
-#   - php mbstring
-ENV VERSION_ONIG=6.9.8
-ENV ONIG_BUILD_DIR=${BUILD_DIR}/oniguruma
-RUN set -xe; \
-    mkdir -p ${ONIG_BUILD_DIR}; \
-    curl -Ls https://github.com/kkos/oniguruma/releases/download/v${VERSION_ONIG}/onig-${VERSION_ONIG}.tar.gz \
-    | tar xzC ${ONIG_BUILD_DIR} --strip-components=1
-WORKDIR  ${ONIG_BUILD_DIR}/
-RUN set -xe; \
-    ./configure --prefix=${INSTALL_DIR}; \
-    make -j $(nproc); \
-    make install
-
-
-ENV VERSION_PHP=8.1.16
-
-
-ENV PHP_BUILD_DIR=${BUILD_DIR}/php
-RUN set -xe; \
-    mkdir -p ${PHP_BUILD_DIR}; \
-    # Download and upack the source code
-    # --location will follow redirects
-    # --silent will hide the progress, but also the errors: we restore error messages with --show-error
-    # --fail makes sure that curl returns an error instead of fetching the 404 page
-    curl --location --silent --show-error --fail https://www.php.net/get/php-${VERSION_PHP}.tar.gz/from/this/mirror \
-  | tar xzC ${PHP_BUILD_DIR} --strip-components=1
-# Move into the unpackaged code directory
-WORKDIR  ${PHP_BUILD_DIR}/
-
-# Configure the build
-# -fstack-protector-strong : Be paranoid about stack overflows
-# -fpic : Make PHP's main executable position-independent (improves ASLR security mechanism, and has no performance impact on x86_64)
-# -fpie : Support Address Space Layout Randomization (see -fpic)
-# -O3 : Optimize for fastest binaries possible.
-# -I : Add the path to the list of directories to be searched for header files during preprocessing.
-# --enable-option-checking=fatal: make sure invalid --configure-flags are fatal errors instead of just warnings
-# --enable-ftp: because ftp_ssl_connect() needs ftp to be compiled statically (see https://github.com/docker-library/php/issues/236)
-# --enable-mbstring: because otherwise there's no way to get pecl to use it properly (see https://github.com/docker-library/php/issues/195)
-# --with-zlib and --with-zlib-dir: See https://stackoverflow.com/a/42978649/245552
-# --with-pear: necessary for `pecl` to work (to install PHP extensions)
-#
-RUN set -xe \
- && ./buildconf --force \
- && CFLAGS="-fstack-protector-strong -fpic -fpie -O3 -I${INSTALL_DIR}/include -I/usr/include -ffunction-sections -fdata-sections" \
-    CPPFLAGS="-fstack-protector-strong -fpic -fpie -O3 -I${INSTALL_DIR}/include -I/usr/include -ffunction-sections -fdata-sections" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib -Wl,-O1 -Wl,--strip-all -Wl,--hash-style=both -pie" \
-    ./configure \
-        --build=x86_64-pc-linux-gnu \
-        --prefix=${INSTALL_DIR} \
-        --enable-option-checking=fatal \
-        --enable-sockets \
-        --with-config-file-path=${INSTALL_DIR}/etc/php \
-        --with-config-file-scan-dir=${INSTALL_DIR}/etc/php/conf.d:/var/task/php/conf.d \
-        --enable-fpm \
-        --disable-cgi \
-        --enable-cli \
-        --disable-phpdbg \
-        --with-sodium \
-        --with-readline \
-        --with-openssl \
-        --with-zlib=${INSTALL_DIR} \
-        --with-zlib-dir=${INSTALL_DIR} \
-        --with-curl \
-        --enable-exif \
-        --enable-ftp \
-        --with-gettext \
-        --enable-mbstring \
-        --with-pdo-mysql=shared,mysqlnd \
-        --with-mysqli \
-        --enable-pcntl \
-        --with-zip \
-        --enable-bcmath \
-        --with-pdo-pgsql=shared,${INSTALL_DIR} \
-        --enable-intl=shared \
-        --enable-soap \
-        --with-xsl=${INSTALL_DIR} \
-        --with-pear
-RUN make -j $(nproc)
-# Run `make install` and override PEAR's PHAR URL because pear.php.net is down
-RUN set -xe; \
- make install PEAR_INSTALLER_URL='https://github.com/pear/pearweb_phars/raw/master/install-pear-nozlib.phar'; \
- { find ${INSTALL_DIR}/bin ${INSTALL_DIR}/sbin -type f -perm +0111 -exec strip --strip-all '{}' + || true; }; \
- make clean; \
- cp php.ini-production ${INSTALL_DIR}/etc/php/php.ini
-
-# Symlink all our binaries into /opt/bin so that Lambda sees them in the path.
-RUN mkdir -p /opt/bin \
-    && cd /opt/bin \
-    && ln -s ../bref/bin/* . \
-    && ln -s ../bref/sbin/* .
-
-# Install extensions
-# We can install extensions manually or using `pecl`
-RUN pecl install APCu
-
-# Run the next step in the previous environment because the `clean.sh` script needs `find`,
-# which isn't installed by default
-FROM build-environment as build-environment-cleaned
-# Remove extra files to make the layers as slim as possible
-COPY clean.sh /tmp/clean.sh
-RUN /tmp/clean.sh && rm /tmp/clean.sh
-
-
-# Now we start back from a clean image.
-# We get rid of everything that is unnecessary (build tools, source code, and anything else
-# that might have created intermediate layers for docker) by copying online the /opt directory.
-FROM public.ecr.aws/lambda/provided:al2
-ENV PATH="/opt/bin:${PATH}" \
-    LD_LIBRARY_PATH="/opt/bref/lib64:/opt/bref/lib"
-
-# Copy everything we built above into the same dir on the base AmazonLinux container.
-COPY --from=build-environment-cleaned /opt /opt
-
-# Set the workdir to the same directory as in AWS Lambda
-WORKDIR /var/task
diff --git a/runtime/base/php-82.Dockerfile b/runtime/base/php-82.Dockerfile
deleted file mode 100644
index 73ea8d0e6..000000000
--- a/runtime/base/php-82.Dockerfile
+++ /dev/null
@@ -1,139 +0,0 @@
-# The container we build here contains everything needed to compile PHP + PHP.
-#
-# It can be used as a base to compile extra extensions.
-
-# PHP Build
-# https://github.com/php/php-src/releases
-# Needs:
-#   - zlib
-#   - libxml2
-#   - openssl
-#   - readline
-#   - sodium
-
-FROM bref/tmp/step-1/build-environment as build-environment
-
-
-###############################################################################
-# Oniguruma
-# This library is not packaged in PHP since PHP 7.4.
-# See https://github.com/php/php-src/blob/43dc7da8e3719d3e89bd8ec15ebb13f997bbbaa9/UPGRADING#L578-L581
-# We do not install the system version because I didn't manage to make it work...
-# Ideally we shouldn't compile it ourselves.
-# https://github.com/kkos/oniguruma/releases
-# Needed by:
-#   - php mbstring
-ENV VERSION_ONIG=6.9.8
-ENV ONIG_BUILD_DIR=${BUILD_DIR}/oniguruma
-RUN set -xe; \
-    mkdir -p ${ONIG_BUILD_DIR}; \
-    curl -Ls https://github.com/kkos/oniguruma/releases/download/v${VERSION_ONIG}/onig-${VERSION_ONIG}.tar.gz \
-    | tar xzC ${ONIG_BUILD_DIR} --strip-components=1
-WORKDIR  ${ONIG_BUILD_DIR}/
-RUN set -xe; \
-    ./configure --prefix=${INSTALL_DIR}; \
-    make -j $(nproc); \
-    make install
-
-
-ENV VERSION_PHP=8.2.3
-
-
-ENV PHP_BUILD_DIR=${BUILD_DIR}/php
-RUN set -xe; \
-    mkdir -p ${PHP_BUILD_DIR}; \
-    # Download and upack the source code
-    # --location will follow redirects
-    # --silent will hide the progress, but also the errors: we restore error messages with --show-error
-    # --fail makes sure that curl returns an error instead of fetching the 404 page
-    curl --location --silent --show-error --fail https://www.php.net/get/php-${VERSION_PHP}.tar.gz/from/this/mirror \
-  | tar xzC ${PHP_BUILD_DIR} --strip-components=1
-# Move into the unpackaged code directory
-WORKDIR  ${PHP_BUILD_DIR}/
-
-# Configure the build
-# -fstack-protector-strong : Be paranoid about stack overflows
-# -fpic : Make PHP's main executable position-independent (improves ASLR security mechanism, and has no performance impact on x86_64)
-# -fpie : Support Address Space Layout Randomization (see -fpic)
-# -O3 : Optimize for fastest binaries possible.
-# -I : Add the path to the list of directories to be searched for header files during preprocessing.
-# --enable-option-checking=fatal: make sure invalid --configure-flags are fatal errors instead of just warnings
-# --enable-ftp: because ftp_ssl_connect() needs ftp to be compiled statically (see https://github.com/docker-library/php/issues/236)
-# --enable-mbstring: because otherwise there's no way to get pecl to use it properly (see https://github.com/docker-library/php/issues/195)
-# --with-zlib and --with-zlib-dir: See https://stackoverflow.com/a/42978649/245552
-# --with-pear: necessary for `pecl` to work (to install PHP extensions)
-#
-RUN set -xe \
- && ./buildconf --force \
- && CFLAGS="-fstack-protector-strong -fpic -fpie -O3 -I${INSTALL_DIR}/include -I/usr/include -ffunction-sections -fdata-sections" \
-    CPPFLAGS="-fstack-protector-strong -fpic -fpie -O3 -I${INSTALL_DIR}/include -I/usr/include -ffunction-sections -fdata-sections" \
-    LDFLAGS="-L${INSTALL_DIR}/lib64 -L${INSTALL_DIR}/lib -Wl,-O1 -Wl,--strip-all -Wl,--hash-style=both -pie" \
-    ./configure \
-        --build=x86_64-pc-linux-gnu \
-        --prefix=${INSTALL_DIR} \
-        --enable-option-checking=fatal \
-        --enable-sockets \
-        --with-config-file-path=${INSTALL_DIR}/etc/php \
-        --with-config-file-scan-dir=${INSTALL_DIR}/etc/php/conf.d:/var/task/php/conf.d \
-        --enable-fpm \
-        --disable-cgi \
-        --enable-cli \
-        --disable-phpdbg \
-        --with-sodium \
-        --with-readline \
-        --with-openssl \
-        --with-zlib=${INSTALL_DIR} \
-        --with-zlib-dir=${INSTALL_DIR} \
-        --with-curl \
-        --enable-exif \
-        --enable-ftp \
-        --with-gettext \
-        --enable-mbstring \
-        --with-pdo-mysql=shared,mysqlnd \
-        --with-mysqli \
-        --enable-pcntl \
-        --with-zip \
-        --enable-bcmath \
-        --with-pdo-pgsql=shared,${INSTALL_DIR} \
-        --enable-intl=shared \
-        --enable-soap \
-        --with-xsl=${INSTALL_DIR} \
-        --with-pear
-RUN make -j $(nproc)
-# Run `make install` and override PEAR's PHAR URL because pear.php.net is down
-RUN set -xe; \
- make install PEAR_INSTALLER_URL='https://github.com/pear/pearweb_phars/raw/master/install-pear-nozlib.phar'; \
- { find ${INSTALL_DIR}/bin ${INSTALL_DIR}/sbin -type f -perm +0111 -exec strip --strip-all '{}' + || true; }; \
- make clean; \
- cp php.ini-production ${INSTALL_DIR}/etc/php/php.ini
-
-# Symlink all our binaries into /opt/bin so that Lambda sees them in the path.
-RUN mkdir -p /opt/bin \
-    && cd /opt/bin \
-    && ln -s ../bref/bin/* . \
-    && ln -s ../bref/sbin/* .
-
-# Install extensions
-# We can install extensions manually or using `pecl`
-RUN pecl install APCu
-
-# Run the next step in the previous environment because the `clean.sh` script needs `find`,
-# which isn't installed by default
-FROM build-environment as build-environment-cleaned
-# Remove extra files to make the layers as slim as possible
-COPY clean.sh /tmp/clean.sh
-RUN /tmp/clean.sh && rm /tmp/clean.sh
-
-
-# Now we start back from a clean image.
-# We get rid of everything that is unnecessary (build tools, source code, and anything else
-# that might have created intermediate layers for docker) by copying online the /opt directory.
-FROM public.ecr.aws/lambda/provided:al2
-ENV PATH="/opt/bin:${PATH}" \
-    LD_LIBRARY_PATH="/opt/bref/lib64:/opt/bref/lib"
-
-# Copy everything we built above into the same dir on the base AmazonLinux container.
-COPY --from=build-environment-cleaned /opt /opt
-
-# Set the workdir to the same directory as in AWS Lambda
-WORKDIR /var/task
diff --git a/runtime/helpers/install-composer.sh b/runtime/helpers/install-composer.sh
deleted file mode 100755
index b6530be11..000000000
--- a/runtime/helpers/install-composer.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/bin/sh
-
-# See https://getcomposer.org/doc/faqs/how-to-install-composer-programmatically.md
-
-EXPECTED_CHECKSUM="$(php -r 'copy("https://composer.github.io/installer.sig", "php://stdout");')"
-php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
-ACTUAL_CHECKSUM="$(php -r "echo hash_file('sha384', 'composer-setup.php');")"
-
-if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]
-then
-    >&2 echo 'ERROR: Invalid installer checksum'
-    rm composer-setup.php
-    exit 1
-fi
-
-php composer-setup.php --quiet
-RESULT=$?
-rm composer-setup.php
-exit $RESULT
diff --git a/runtime/helpers/php/conf.d/php.ini b/runtime/helpers/php/conf.d/php.ini
deleted file mode 100644
index 6e5acd752..000000000
--- a/runtime/helpers/php/conf.d/php.ini
+++ /dev/null
@@ -1,3 +0,0 @@
-extension=intl
-extension=apcu
-extension=pdo_pgsql
diff --git a/runtime/helpers/php/conf.dev.d/php.ini b/runtime/helpers/php/conf.dev.d/php.ini
deleted file mode 100644
index 9d1fa25c2..000000000
--- a/runtime/helpers/php/conf.dev.d/php.ini
+++ /dev/null
@@ -1,2 +0,0 @@
-extension=blackfire
-zend_extension=xdebug
diff --git a/runtime/layers/console/Dockerfile b/runtime/layers/console/Dockerfile
deleted file mode 100644
index 5ef1e3185..000000000
--- a/runtime/layers/console/Dockerfile
+++ /dev/null
@@ -1,8 +0,0 @@
-ARG PHP_VERSION
-FROM bref/php-$PHP_VERSION
-
-# Overwrite the original bootstrap
-COPY bootstrap /opt/bootstrap
-COPY breftoolbox.php /opt/breftoolbox.php
-# Copy files to /var/runtime to support deploying as a Docker image
-RUN cp /opt/bootstrap /var/runtime
diff --git a/runtime/layers/console/README.md b/runtime/layers/console/README.md
deleted file mode 100644
index d8fc2cdc7..000000000
--- a/runtime/layers/console/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-The "console" layer is a layer that comes on top of the PHP runtime. It lets us execute console commands on lambda.
-
-This layer overrides the `bootstrap` to execute CLI console commands (e.g. Symfony Console or Laravel Artisan).
-
-Read more at [bref.sh/docs/runtimes/console.html](https://bref.sh/docs/runtimes/console.html).
diff --git a/runtime/layers/console/bootstrap b/runtime/layers/console/bootstrap
deleted file mode 100755
index f7ae27b76..000000000
--- a/runtime/layers/console/bootstrap
+++ /dev/null
@@ -1,82 +0,0 @@
-#!/opt/bin/php
-<?php declare(strict_types=1);
-
-use Bref\Context\Context;
-use Bref\Runtime\LambdaRuntime;
-use Symfony\Component\Process\Process;
-
-ini_set('display_errors', '1');
-error_reporting(E_ALL);
-
-// A wrapper script is configured
-// See https://docs.aws.amazon.com/lambda/latest/dg/runtimes-modify.html#runtime-wrapper
-// NOTE: EXPERIMENTAL FEATURE, DO NOT USE!!!
-// Note: If you do use it, open an issue or GitHub discussion or Slack thread
-// and let us know why it's useful to you, we might turn it into an official feature
-if (getenv('EXPERIMENTAL_AWS_LAMBDA_EXEC_WRAPPER')) {
-    $cmd = escapeshellcmd(getenv('EXPERIMENTAL_AWS_LAMBDA_EXEC_WRAPPER'));
-    $args = implode(' ', array_map('escapeshellarg', $argv));
-    passthru("$cmd $args", $exitCode);
-    exit($exitCode);
-}
-
-$appRoot = getenv('LAMBDA_TASK_ROOT');
-
-if (getenv('BREF_DOWNLOAD_VENDOR')) {
-    if(! file_exists('/tmp/vendor') || ! file_exists('/tmp/vendor/autoload.php')) {
-        require_once __DIR__ . '/breftoolbox.php';
-
-        \Bref\ToolBox\BrefToolBox::downloadAndConfigureVendor();
-    }
-
-    require '/tmp/vendor/autoload.php';
-} elseif (getenv('BREF_AUTOLOAD_PATH')) {
-    /** @noinspection PhpIncludeInspection */
-    require getenv('BREF_AUTOLOAD_PATH');
-} else {
-    /** @noinspection PhpIncludeInspection */
-    require $appRoot . '/vendor/autoload.php';
-}
-
-$lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('console');
-
-$handlerFile = $appRoot . '/' . getenv('_HANDLER');
-if (! is_file($handlerFile)) {
-    $lambdaRuntime->failInitialization("Handler `$handlerFile` doesn't exist");
-}
-
-while (true) {
-    $lambdaRuntime->processNextEvent(function ($event, Context $context) use ($handlerFile): array {
-        if (is_array($event)) {
-            // Backward compatibility with the former CLI invocation format
-            $cliOptions = $event['cli'] ?? '';
-        } elseif (is_string($event)) {
-            $cliOptions = $event;
-        } else {
-            $cliOptions = '';
-        }
-
-        $timeout = max(1, $context->getRemainingTimeInMillis() / 1000 - 1);
-        $command = sprintf('/opt/bin/php %s %s 2>&1', $handlerFile, $cliOptions);
-        if (\method_exists(Process::class, 'fromShellCommandline')) {
-            $process = Process::fromShellCommandline($command, null, ['LAMBDA_INVOCATION_CONTEXT' => json_encode($context)], null, $timeout);
-        } else {
-            $process = new Process($command, null, ['LAMBDA_INVOCATION_CONTEXT' => json_encode($context)], null, $timeout);
-        }
-
-        $process->run(function ($type, $buffer) {
-            echo $buffer;
-        });
-
-        $exitCode = $process->getExitCode();
-
-        if ($exitCode > 0) {
-            throw new Exception('The command exited with a non-zero status code: ' . $exitCode);
-        }
-
-        return [
-            'exitCode' => $exitCode, // will always be 0
-            'output' => $process->getOutput(),
-        ];
-    });
-}
diff --git a/runtime/layers/console/breftoolbox.php b/runtime/layers/console/breftoolbox.php
deleted file mode 100644
index 0bf607102..000000000
--- a/runtime/layers/console/breftoolbox.php
+++ /dev/null
@@ -1,221 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\ToolBox;
-
-use Exception;
-use ZipArchive;
-use function curl_close;
-use function curl_exec;
-use function curl_init;
-use function curl_setopt_array;
-use function fclose;
-use function file_exists;
-use function file_get_contents;
-use function file_put_contents;
-use function fopen;
-use function getenv;
-use function gmdate;
-use function hash;
-use function hash_hmac;
-use function ksort;
-use function mkdir;
-use function preg_match;
-use function rawurlencode;
-use function rtrim;
-use function sprintf;
-use function str_replace;
-use function substr;
-use function time;
-use function trim;
-use function unlink;
-use const CURLOPT_FILE;
-use const CURLOPT_FOLLOWLOCATION;
-use const CURLOPT_HEADER;
-
-/**
- * @internal
- */
-class BrefToolBox
-{
-    /**
-     * Path to autoload_static.php file from composer
-     */
-    const AUTOLOAD_STATIC_PATH = '/tmp/vendor/composer/autoload_static.php';
-    /**
-     * Path where the vendor archive will be stored
-     */
-    const DOWNLOAD_FILE_PATH = '/tmp/vendor.zip';
-    /**
-     * Path where the extracted vendor archives contents will be stored
-     */
-    const EXTRACTION_PATH = '/tmp/vendor/';
-
-    /**
-     * @throws Exception
-     */
-    public static function downloadAndConfigureVendor()
-    {
-        self::downloadVendorArchive(
-            getenv('BREF_DOWNLOAD_VENDOR'),
-            self::DOWNLOAD_FILE_PATH
-        );
-
-        $unzipped = self::unzipVendorArchive(
-            self::DOWNLOAD_FILE_PATH,
-            self::EXTRACTION_PATH
-        );
-
-        if (!$unzipped) {
-            throw new Exception('Unable to unzip vendor archive.');
-        }
-
-        unlink(self::DOWNLOAD_FILE_PATH);
-
-        self::updateComposerAutoloading();
-    }
-
-    /**
-     * Download vendor archive from s3 bucket
-     */
-    private static function downloadVendorArchive(string $s3String, string $downloadPath): void
-    {
-        preg_match('~s3\:\/\/([^\/]+)\/(.*)~', $s3String, $matches);
-        $bucket = $matches[1];
-        $filePath = '/' . $matches[2];
-        $region = getenv('AWS_REGION');
-
-        $url = self::AWS_S3_PresignDownload(
-            getenv('AWS_ACCESS_KEY_ID'),
-            getenv('AWS_SECRET_ACCESS_KEY'),
-            getenv('AWS_SESSION_TOKEN'),
-            $bucket,
-            $region,
-            $filePath
-        );
-
-        if (file_exists($downloadPath)) {
-            unlink($downloadPath);
-        }
-
-        $fp = fopen($downloadPath, 'w');
-
-        $options = [
-            CURLOPT_HEADER => 0,
-            CURLOPT_FOLLOWLOCATION => 1,
-            CURLOPT_FILE => $fp,
-        ];
-
-        $ch = curl_init($url);
-        curl_setopt_array($ch, $options);
-        curl_exec($ch);
-        curl_close($ch);
-        fclose($fp);
-    }
-
-    /**
-     * Create S3 signed url to download vendor archive from
-     * From https://gist.github.com/anthonyeden/4448695ad531016ec12bcdacc9d91cb8
-     */
-    private static function AWS_S3_PresignDownload(
-        string $AWSAccessKeyId,
-        string $AWSSecretAccessKey,
-        string $AWSSessionToken,
-        string $BucketName,
-        string $AWSRegion,
-        string $canonical_uri,
-        int $expires = 86400
-    ): string {
-        // Creates a signed download link for an AWS S3 file
-        // Based on https://gist.github.com/kelvinmo/d78be66c4f36415a6b80
-
-        $encoded_uri = str_replace('%2F', '/', rawurlencode($canonical_uri));
-
-        // Specify the hostname for the S3 endpoint
-        if ($AWSRegion == 'us-east-1') {
-            $hostname = trim($BucketName . ".s3.amazonaws.com");
-            $header_string = "host:" . $hostname . "\n";
-            $signed_headers_string = "host";
-        } else {
-            $hostname = trim($BucketName . ".s3-" . $AWSRegion . ".amazonaws.com");
-            $header_string = "host:" . $hostname . "\n";
-            $signed_headers_string = "host";
-        }
-
-        $date_text = gmdate('Ymd', time());
-        $time_text = $date_text . 'T000000Z';
-        $algorithm = 'AWS4-HMAC-SHA256';
-        $scope = $date_text . "/" . $AWSRegion . "/s3/aws4_request";
-
-        $x_amz_params = array(
-            'X-Amz-Algorithm' => $algorithm,
-            'X-Amz-Credential' => $AWSAccessKeyId . '/' . $scope,
-            'X-Amz-Date' => $time_text,
-            'X-Amz-SignedHeaders' => $signed_headers_string,
-            'X-Amz-Security-Token' => $AWSSessionToken,
-        );
-
-        if ($expires > 0) {
-            // 'Expires' is the number of seconds until the request becomes invalid
-            $x_amz_params['X-Amz-Expires'] = (string)$expires;
-        }
-
-        ksort($x_amz_params);
-
-        $query_string = "";
-        foreach ($x_amz_params as $key => $value) {
-            $query_string .= rawurlencode($key) . '=' . rawurlencode($value) . "&";
-        }
-        $query_string = substr($query_string, 0, -1);
-
-        $canonical_request = "GET\n" . $encoded_uri . "\n" . $query_string . "\n" . $header_string . "\n" . $signed_headers_string . "\nUNSIGNED-PAYLOAD";
-        $string_to_sign = $algorithm . "\n" . $time_text . "\n" . $scope . "\n" . hash('sha256', $canonical_request,
-                false);
-        $signing_key = hash_hmac('sha256', 'aws4_request',
-            hash_hmac('sha256', 's3',
-                hash_hmac('sha256', $AWSRegion,
-                    hash_hmac('sha256', $date_text, 'AWS4' . $AWSSecretAccessKey, true),
-                    true),
-                true),
-            true
-        );
-        $signature = hash_hmac('sha256', $string_to_sign, $signing_key);
-
-        return 'https://' . $hostname . $encoded_uri . '?' . $query_string . '&X-Amz-Signature=' . $signature;
-    }
-
-    /**
-     * Unzip vendor archive into temporary path
-     */
-    private static function unzipVendorArchive(string $filePath, string $unzipPath): bool
-    {
-        $zip = new ZipArchive();
-        $resource = $zip->open($filePath);
-
-        if (!file_exists($unzipPath)) {
-            mkdir($unzipPath, 0755, true);
-        }
-
-        if ($resource !== true) {
-            return false;
-        }
-
-        $zip->extractTo($unzipPath);
-        $zip->close();
-
-        return true;
-    }
-
-    /**
-     * Update composer autoload_static.php file with correct path for Lambda task root
-     */
-    private static function updateComposerAutoloading(): void
-    {
-        $updatedStaticLoader = str_replace(
-            "__DIR__ . '/../..'",
-            sprintf("'%s'", rtrim(getenv('LAMBDA_TASK_ROOT'), '/')),
-            file_get_contents(self::AUTOLOAD_STATIC_PATH)
-        );
-
-        file_put_contents(self::AUTOLOAD_STATIC_PATH, $updatedStaticLoader);
-    }
-}
diff --git a/runtime/layers/fpm-dev/Dockerfile b/runtime/layers/fpm-dev/Dockerfile
deleted file mode 100644
index ca25646eb..000000000
--- a/runtime/layers/fpm-dev/Dockerfile
+++ /dev/null
@@ -1,35 +0,0 @@
-ARG PHP_VERSION
-FROM bref/build-php-$PHP_VERSION as build_extensions
-
-# Re-expose the arg again inside this "FROM" block
-ARG PHP_VERSION
-
-RUN if [[ "$PHP_VERSION" != 7* ]]; then pecl install xdebug; fi
-RUN if [[ "$PHP_VERSION" == 7* ]]; then pecl install xdebug-3.1.6; fi
-RUN cp $(php -r "echo ini_get('extension_dir');")/xdebug.so /tmp
-
-RUN version=$(php -r "echo PHP_MAJOR_VERSION.PHP_MINOR_VERSION;") \
-    && curl -A "Docker" -o /tmp/blackfire.so -L -s "https://packages.blackfire.io/binaries/blackfire-php/1.55.0/blackfire-php-linux_amd64-php-"$version".so" ;
-
-FROM bref/php-${PHP_VERSION}-fpm as build_dev
-
-USER root
-COPY --from=build_extensions /tmp/*.so /tmp/
-RUN cp /tmp/*.so $(php -r "echo ini_get('extension_dir');")
-
-FROM bref/php-${PHP_VERSION}-fpm
-
-COPY --from=build_dev  /opt /opt
-# Override the config so that PHP-FPM listens on port 9000
-COPY php-fpm.conf /opt/bref/etc/php-fpm.conf
-
-EXPOSE 9001
-
-# Clear the parent entrypoint
-ENTRYPOINT []
-
-ENV PHP_INI_SCAN_DIR="/opt/bref/etc/php/conf.d:/var/task/php/conf.d:/var/task/php/conf.dev.d"
-
-# Run PHP-FPM
-# opcache.validate_timestamps=1 : cancels the flag in the base configuration so that files are reloaded
-CMD /opt/bin/php-fpm --nodaemonize --fpm-config /opt/bref/etc/php-fpm.conf -d opcache.validate_timestamps=1 --force-stderr
diff --git a/runtime/layers/fpm-dev/php-fpm.conf b/runtime/layers/fpm-dev/php-fpm.conf
deleted file mode 100644
index 99c903e3a..000000000
--- a/runtime/layers/fpm-dev/php-fpm.conf
+++ /dev/null
@@ -1,26 +0,0 @@
-; Logging anywhere on disk doesn't make sense on lambda since instances are ephemeral
-error_log = /dev/null
-pid = /tmp/php-fpm.pid
-; Log above warning because PHP-FPM logs useless notices
-; We must comment this flag else uncaught exceptions/fatal errors are not reported in the logs!
-; TODO: report that to the PHP bug tracker
-;log_level = 'warning'
-
-; New PHP 7.3 option that includes the maximum length when writing to stderr
-log_limit = 8192
-
-[default]
-pm = static
-; We only need one child because a lambda can process only one request at a time
-pm.max_children = 1
-user = nobody
-listen = 9001
-; Allows PHP processes to access the lambda's environment variables
-clear_env = no
-; Forward stderr of PHP processes to stderr of PHP-FPM (so that it can be sent to cloudwatch)
-catch_workers_output = yes
-; New PHP 7.3 option that disables a verbose log prefix
-decorate_workers_output = no
-; Limit the number of core dump logs to 1 to avoid filling up the /tmp disk
-; See https://github.com/brefphp/bref/issues/275
-rlimit_core = 1
diff --git a/runtime/layers/fpm/Dockerfile b/runtime/layers/fpm/Dockerfile
deleted file mode 100644
index ca91b308a..000000000
--- a/runtime/layers/fpm/Dockerfile
+++ /dev/null
@@ -1,14 +0,0 @@
-ARG PHP_VERSION
-FROM bref/tmp/cleaned-build-php-$PHP_VERSION
-ARG PHP_VERSION
-
-COPY bootstrap /opt/bootstrap
-COPY breftoolbox.php /opt/breftoolbox.php
-COPY php.ini /opt/bref/etc/php/conf.d/bref.ini
-COPY php-fpm.conf /opt/bref/etc/php-fpm.conf
-
-# Build the final image from the amazon image that is close to the production environment
-FROM public.ecr.aws/lambda/provided:al2
-COPY --from=0 /opt /opt
-# Copy files to /var/runtime to support deploying as a Docker image
-RUN cp /opt/bootstrap /var/runtime && cp /opt/breftoolbox.php /var/runtime
diff --git a/runtime/layers/fpm/bootstrap b/runtime/layers/fpm/bootstrap
deleted file mode 100755
index df6f5aac4..000000000
--- a/runtime/layers/fpm/bootstrap
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/opt/bin/php
-<?php declare(strict_types=1);
-
-use Bref\Event\Http\FpmHandler;
-use Bref\Runtime\LambdaRuntime;
-
-ini_set('display_errors', '1');
-error_reporting(E_ALL);
-
-// A wrapper script is configured
-// See https://docs.aws.amazon.com/lambda/latest/dg/runtimes-modify.html#runtime-wrapper
-// NOTE: EXPERIMENTAL FEATURE, DO NOT USE!!!
-// Note: If you do use it, open an issue or GitHub discussion or Slack thread
-// and let us know why it's useful to you, we might turn it into an official feature
-if (getenv('EXPERIMENTAL_AWS_LAMBDA_EXEC_WRAPPER')) {
-    $cmd = escapeshellcmd(getenv('EXPERIMENTAL_AWS_LAMBDA_EXEC_WRAPPER'));
-    $args = implode(' ', array_map('escapeshellarg', $argv));
-    passthru("$cmd $args", $exitCode);
-    exit($exitCode);
-}
-
-$appRoot = getenv('LAMBDA_TASK_ROOT');
-
-if (getenv('BREF_DOWNLOAD_VENDOR')) {
-    if(! file_exists('/tmp/vendor') || ! file_exists('/tmp/vendor/autoload.php')) {
-        require_once __DIR__ . '/breftoolbox.php';
-
-        \Bref\ToolBox\BrefToolBox::downloadAndConfigureVendor();
-    }
-
-    require '/tmp/vendor/autoload.php';
-} elseif (getenv('BREF_AUTOLOAD_PATH')) {
-    /** @noinspection PhpIncludeInspection */
-    require getenv('BREF_AUTOLOAD_PATH');
-} else {
-    /** @noinspection PhpIncludeInspection */
-    require $appRoot . '/vendor/autoload.php';
-}
-
-$lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('fpm');
-
-$handlerFile = $appRoot . '/' . getenv('_HANDLER');
-if (! is_file($handlerFile)) {
-    $lambdaRuntime->failInitialization("Handler `$handlerFile` doesn't exist");
-}
-
-$phpFpm = new FpmHandler($handlerFile);
-try {
-    $phpFpm->start();
-} catch (\Throwable $e) {
-    $lambdaRuntime->failInitialization('Error while starting PHP-FPM', $e);
-}
-
-while (true) {
-    $lambdaRuntime->processNextEvent($phpFpm);
-}
diff --git a/runtime/layers/fpm/breftoolbox.php b/runtime/layers/fpm/breftoolbox.php
deleted file mode 100644
index 0bf607102..000000000
--- a/runtime/layers/fpm/breftoolbox.php
+++ /dev/null
@@ -1,221 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\ToolBox;
-
-use Exception;
-use ZipArchive;
-use function curl_close;
-use function curl_exec;
-use function curl_init;
-use function curl_setopt_array;
-use function fclose;
-use function file_exists;
-use function file_get_contents;
-use function file_put_contents;
-use function fopen;
-use function getenv;
-use function gmdate;
-use function hash;
-use function hash_hmac;
-use function ksort;
-use function mkdir;
-use function preg_match;
-use function rawurlencode;
-use function rtrim;
-use function sprintf;
-use function str_replace;
-use function substr;
-use function time;
-use function trim;
-use function unlink;
-use const CURLOPT_FILE;
-use const CURLOPT_FOLLOWLOCATION;
-use const CURLOPT_HEADER;
-
-/**
- * @internal
- */
-class BrefToolBox
-{
-    /**
-     * Path to autoload_static.php file from composer
-     */
-    const AUTOLOAD_STATIC_PATH = '/tmp/vendor/composer/autoload_static.php';
-    /**
-     * Path where the vendor archive will be stored
-     */
-    const DOWNLOAD_FILE_PATH = '/tmp/vendor.zip';
-    /**
-     * Path where the extracted vendor archives contents will be stored
-     */
-    const EXTRACTION_PATH = '/tmp/vendor/';
-
-    /**
-     * @throws Exception
-     */
-    public static function downloadAndConfigureVendor()
-    {
-        self::downloadVendorArchive(
-            getenv('BREF_DOWNLOAD_VENDOR'),
-            self::DOWNLOAD_FILE_PATH
-        );
-
-        $unzipped = self::unzipVendorArchive(
-            self::DOWNLOAD_FILE_PATH,
-            self::EXTRACTION_PATH
-        );
-
-        if (!$unzipped) {
-            throw new Exception('Unable to unzip vendor archive.');
-        }
-
-        unlink(self::DOWNLOAD_FILE_PATH);
-
-        self::updateComposerAutoloading();
-    }
-
-    /**
-     * Download vendor archive from s3 bucket
-     */
-    private static function downloadVendorArchive(string $s3String, string $downloadPath): void
-    {
-        preg_match('~s3\:\/\/([^\/]+)\/(.*)~', $s3String, $matches);
-        $bucket = $matches[1];
-        $filePath = '/' . $matches[2];
-        $region = getenv('AWS_REGION');
-
-        $url = self::AWS_S3_PresignDownload(
-            getenv('AWS_ACCESS_KEY_ID'),
-            getenv('AWS_SECRET_ACCESS_KEY'),
-            getenv('AWS_SESSION_TOKEN'),
-            $bucket,
-            $region,
-            $filePath
-        );
-
-        if (file_exists($downloadPath)) {
-            unlink($downloadPath);
-        }
-
-        $fp = fopen($downloadPath, 'w');
-
-        $options = [
-            CURLOPT_HEADER => 0,
-            CURLOPT_FOLLOWLOCATION => 1,
-            CURLOPT_FILE => $fp,
-        ];
-
-        $ch = curl_init($url);
-        curl_setopt_array($ch, $options);
-        curl_exec($ch);
-        curl_close($ch);
-        fclose($fp);
-    }
-
-    /**
-     * Create S3 signed url to download vendor archive from
-     * From https://gist.github.com/anthonyeden/4448695ad531016ec12bcdacc9d91cb8
-     */
-    private static function AWS_S3_PresignDownload(
-        string $AWSAccessKeyId,
-        string $AWSSecretAccessKey,
-        string $AWSSessionToken,
-        string $BucketName,
-        string $AWSRegion,
-        string $canonical_uri,
-        int $expires = 86400
-    ): string {
-        // Creates a signed download link for an AWS S3 file
-        // Based on https://gist.github.com/kelvinmo/d78be66c4f36415a6b80
-
-        $encoded_uri = str_replace('%2F', '/', rawurlencode($canonical_uri));
-
-        // Specify the hostname for the S3 endpoint
-        if ($AWSRegion == 'us-east-1') {
-            $hostname = trim($BucketName . ".s3.amazonaws.com");
-            $header_string = "host:" . $hostname . "\n";
-            $signed_headers_string = "host";
-        } else {
-            $hostname = trim($BucketName . ".s3-" . $AWSRegion . ".amazonaws.com");
-            $header_string = "host:" . $hostname . "\n";
-            $signed_headers_string = "host";
-        }
-
-        $date_text = gmdate('Ymd', time());
-        $time_text = $date_text . 'T000000Z';
-        $algorithm = 'AWS4-HMAC-SHA256';
-        $scope = $date_text . "/" . $AWSRegion . "/s3/aws4_request";
-
-        $x_amz_params = array(
-            'X-Amz-Algorithm' => $algorithm,
-            'X-Amz-Credential' => $AWSAccessKeyId . '/' . $scope,
-            'X-Amz-Date' => $time_text,
-            'X-Amz-SignedHeaders' => $signed_headers_string,
-            'X-Amz-Security-Token' => $AWSSessionToken,
-        );
-
-        if ($expires > 0) {
-            // 'Expires' is the number of seconds until the request becomes invalid
-            $x_amz_params['X-Amz-Expires'] = (string)$expires;
-        }
-
-        ksort($x_amz_params);
-
-        $query_string = "";
-        foreach ($x_amz_params as $key => $value) {
-            $query_string .= rawurlencode($key) . '=' . rawurlencode($value) . "&";
-        }
-        $query_string = substr($query_string, 0, -1);
-
-        $canonical_request = "GET\n" . $encoded_uri . "\n" . $query_string . "\n" . $header_string . "\n" . $signed_headers_string . "\nUNSIGNED-PAYLOAD";
-        $string_to_sign = $algorithm . "\n" . $time_text . "\n" . $scope . "\n" . hash('sha256', $canonical_request,
-                false);
-        $signing_key = hash_hmac('sha256', 'aws4_request',
-            hash_hmac('sha256', 's3',
-                hash_hmac('sha256', $AWSRegion,
-                    hash_hmac('sha256', $date_text, 'AWS4' . $AWSSecretAccessKey, true),
-                    true),
-                true),
-            true
-        );
-        $signature = hash_hmac('sha256', $string_to_sign, $signing_key);
-
-        return 'https://' . $hostname . $encoded_uri . '?' . $query_string . '&X-Amz-Signature=' . $signature;
-    }
-
-    /**
-     * Unzip vendor archive into temporary path
-     */
-    private static function unzipVendorArchive(string $filePath, string $unzipPath): bool
-    {
-        $zip = new ZipArchive();
-        $resource = $zip->open($filePath);
-
-        if (!file_exists($unzipPath)) {
-            mkdir($unzipPath, 0755, true);
-        }
-
-        if ($resource !== true) {
-            return false;
-        }
-
-        $zip->extractTo($unzipPath);
-        $zip->close();
-
-        return true;
-    }
-
-    /**
-     * Update composer autoload_static.php file with correct path for Lambda task root
-     */
-    private static function updateComposerAutoloading(): void
-    {
-        $updatedStaticLoader = str_replace(
-            "__DIR__ . '/../..'",
-            sprintf("'%s'", rtrim(getenv('LAMBDA_TASK_ROOT'), '/')),
-            file_get_contents(self::AUTOLOAD_STATIC_PATH)
-        );
-
-        file_put_contents(self::AUTOLOAD_STATIC_PATH, $updatedStaticLoader);
-    }
-}
diff --git a/runtime/layers/fpm/php-fpm.conf b/runtime/layers/fpm/php-fpm.conf
deleted file mode 100644
index ffe381289..000000000
--- a/runtime/layers/fpm/php-fpm.conf
+++ /dev/null
@@ -1,25 +0,0 @@
-; Logging anywhere on disk doesn't make sense on lambda since instances are ephemeral
-error_log = /dev/null
-pid = /tmp/.bref/php-fpm.pid
-; Log above warning because PHP-FPM logs useless notices
-; We must comment this flag else uncaught exceptions/fatal errors are not reported in the logs!
-; TODO: report that to the PHP bug tracker
-;log_level = 'warning'
-
-; New PHP 7.3 option that includes the maximum length when writing to stderr
-log_limit = 8192
-
-[default]
-pm = static
-; We only need one child because a lambda can process only one request at a time
-pm.max_children = 1
-listen = /tmp/.bref/php-fpm.sock
-; Allows PHP processes to access the lambda's environment variables
-clear_env = no
-; Forward stderr of PHP processes to stderr of PHP-FPM (so that it can be sent to cloudwatch)
-catch_workers_output = yes
-; New PHP 7.3 option that disables a verbose log prefix
-decorate_workers_output = no
-; Limit the number of core dump logs to 1 to avoid filling up the /tmp disk
-; See https://github.com/brefphp/bref/issues/275
-rlimit_core = 1
diff --git a/runtime/layers/fpm/php.ini b/runtime/layers/fpm/php.ini
deleted file mode 100644
index c523f495d..000000000
--- a/runtime/layers/fpm/php.ini
+++ /dev/null
@@ -1,50 +0,0 @@
-; Do not display errors in production because with PHP-FPM that means
-; errors will be output in the HTTP response
-display_errors=0
-
-; Since PHP 7.4 the default value is E_ALL
-; We override it to set the recommended configuration value for production.
-; See https://github.com/php/php-src/blob/d91abf76e01a3c39424e8192ad049f473f900936/php.ini-production#L463
-error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
-
-memory_limit=3008M
-
-opcache.enable=1
-
-; Skip this check to save a bit
-opcache.validate_permission=0
-
-; The code is readonly on lambdas so it never changes
-; This setting is now disabled: code could be written to /tmp which is read/write
-; (e.g. a compiled container) Such a performance optimization can be done by users.
-;opcache.validate_timestamps=0
-
-; Set sane values, modern PHP applications have higher needs than opcache's defaults
-; See https://tideways.com/profiler/blog/fine-tune-your-opcache-configuration-to-avoid-caching-suprises
-opcache.memory_consumption=128
-opcache.max_accelerated_files=10000
-
-zend_extension=opcache.so
-
-; This directive determines which super global arrays are registered when PHP
-; starts up. G,P,C,E & S are abbreviations for the following respective super
-; globals: GET, POST, COOKIE, ENV and SERVER.
-; We explicitly populate all variables else ENV is not populated by default.
-; See https://github.com/brefphp/bref/pull/291
-variables_order="EGPCS"
-
-; The lambda environment is not compatible with fastcgi_finish_request
-; See https://github.com/brefphp/bref/issues/214
-disable_functions=fastcgi_finish_request
-
-; API Gateway has a timeout of 29 seconds. Setting this to 28 will give PHP some
-; time to properly finish up its resources and flush logs to CloudWatch.
-max_execution_time=28
-
-; The total upload size limit is 6Mb, we override the defaults to match this limit
-; API Gateway has a 10Mb limit, but Lambda's is 6Mb
-post_max_size=6M
-upload_max_filesize=6M
-
-; Enable the PDO MySQL extension
-extension=pdo_mysql
diff --git a/runtime/layers/function/Dockerfile b/runtime/layers/function/Dockerfile
deleted file mode 100644
index 50f8804f3..000000000
--- a/runtime/layers/function/Dockerfile
+++ /dev/null
@@ -1,16 +0,0 @@
-ARG PHP_VERSION
-FROM bref/tmp/cleaned-build-php-$PHP_VERSION
-
-COPY bootstrap /opt/bootstrap
-COPY bootstrap.php /opt/bref/bootstrap.php
-COPY breftoolbox.php /opt/bref/breftoolbox.php
-COPY php.ini /opt/bref/etc/php/conf.d/bref.ini
-
-# Remove PHP-FPM
-RUN rm /opt/bref/sbin/php-fpm /opt/bin/php-fpm
-
-# Build the final image from the amazon image that is close to the production environment
-FROM public.ecr.aws/lambda/provided:al2
-COPY --from=0 /opt /opt
-# Copy files to /var/runtime to support deploying as a Docker image
-RUN cp /opt/bootstrap /var/runtime
diff --git a/runtime/layers/function/bootstrap b/runtime/layers/function/bootstrap
deleted file mode 100755
index 5f4baa691..000000000
--- a/runtime/layers/function/bootstrap
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/bin/sh
-
-# Fail on error
-set -e
-
-while true
-do
-    if [ -z "${EXPERIMENTAL_AWS_LAMBDA_EXEC_WRAPPER}" ]; then
-        # Default behavior
-
-        # We redirect stderr to stdout so that everything
-        # written on the output ends up in Cloudwatch automatically
-        /opt/bin/php "/opt/bref/bootstrap.php" 2>&1
-    else
-        # A wrapper script is configured
-        # See https://docs.aws.amazon.com/lambda/latest/dg/runtimes-modify.html#runtime-wrapper
-        # NOTE: EXPERIMENTAL FEATURE, DO NOT USE!!!
-        # Note: If you do use it, open an issue or GitHub discussion or Slack thread
-        # and let us know why it's useful to you, we might turn it into an official feature
-        "${EXPERIMENTAL_AWS_LAMBDA_EXEC_WRAPPER}" /opt/bin/php "/opt/bref/bootstrap.php" 2>&1
-    fi
-
-done
diff --git a/runtime/layers/function/bootstrap.php b/runtime/layers/function/bootstrap.php
deleted file mode 100644
index ddd3f73b0..000000000
--- a/runtime/layers/function/bootstrap.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php declare(strict_types=1);
-
-use Bref\Bref;
-use Bref\Runtime\LambdaRuntime;
-
-ini_set('display_errors', '1');
-error_reporting(E_ALL);
-
-$appRoot = getenv('LAMBDA_TASK_ROOT');
-
-if (getenv('BREF_DOWNLOAD_VENDOR')) {
-    if(! file_exists('/tmp/vendor') || ! file_exists('/tmp/vendor/autoload.php')) {
-        require_once '/opt/bref/breftoolbox.php';
-
-        \Bref\ToolBox\BrefToolBox::downloadAndConfigureVendor();
-    }
-
-    require '/tmp/vendor/autoload.php';
-} elseif (getenv('BREF_AUTOLOAD_PATH')) {
-    /** @noinspection PhpIncludeInspection */
-    require getenv('BREF_AUTOLOAD_PATH');
-} else {
-    /** @noinspection PhpIncludeInspection */
-    require $appRoot . '/vendor/autoload.php';
-}
-
-$lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('function');
-
-$container = Bref::getContainer();
-
-try {
-    $handler = $container->get(getenv('_HANDLER'));
-} catch (Throwable $e) {
-    $lambdaRuntime->failInitialization($e->getMessage());
-}
-
-$loopMax = getenv('BREF_LOOP_MAX') ?: 1;
-$loops = 0;
-while (true) {
-    if (++$loops > $loopMax) {
-        exit(0);
-    }
-    $success = $lambdaRuntime->processNextEvent($handler);
-    // In case the execution failed, we force starting a new process regardless of BREF_LOOP_MAX
-    // Why: an exception could have left the application in a non-clean state, this is preventive
-    if (! $success) {
-        exit(0);
-    }
-}
diff --git a/runtime/layers/function/breftoolbox.php b/runtime/layers/function/breftoolbox.php
deleted file mode 100644
index 0bf607102..000000000
--- a/runtime/layers/function/breftoolbox.php
+++ /dev/null
@@ -1,221 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\ToolBox;
-
-use Exception;
-use ZipArchive;
-use function curl_close;
-use function curl_exec;
-use function curl_init;
-use function curl_setopt_array;
-use function fclose;
-use function file_exists;
-use function file_get_contents;
-use function file_put_contents;
-use function fopen;
-use function getenv;
-use function gmdate;
-use function hash;
-use function hash_hmac;
-use function ksort;
-use function mkdir;
-use function preg_match;
-use function rawurlencode;
-use function rtrim;
-use function sprintf;
-use function str_replace;
-use function substr;
-use function time;
-use function trim;
-use function unlink;
-use const CURLOPT_FILE;
-use const CURLOPT_FOLLOWLOCATION;
-use const CURLOPT_HEADER;
-
-/**
- * @internal
- */
-class BrefToolBox
-{
-    /**
-     * Path to autoload_static.php file from composer
-     */
-    const AUTOLOAD_STATIC_PATH = '/tmp/vendor/composer/autoload_static.php';
-    /**
-     * Path where the vendor archive will be stored
-     */
-    const DOWNLOAD_FILE_PATH = '/tmp/vendor.zip';
-    /**
-     * Path where the extracted vendor archives contents will be stored
-     */
-    const EXTRACTION_PATH = '/tmp/vendor/';
-
-    /**
-     * @throws Exception
-     */
-    public static function downloadAndConfigureVendor()
-    {
-        self::downloadVendorArchive(
-            getenv('BREF_DOWNLOAD_VENDOR'),
-            self::DOWNLOAD_FILE_PATH
-        );
-
-        $unzipped = self::unzipVendorArchive(
-            self::DOWNLOAD_FILE_PATH,
-            self::EXTRACTION_PATH
-        );
-
-        if (!$unzipped) {
-            throw new Exception('Unable to unzip vendor archive.');
-        }
-
-        unlink(self::DOWNLOAD_FILE_PATH);
-
-        self::updateComposerAutoloading();
-    }
-
-    /**
-     * Download vendor archive from s3 bucket
-     */
-    private static function downloadVendorArchive(string $s3String, string $downloadPath): void
-    {
-        preg_match('~s3\:\/\/([^\/]+)\/(.*)~', $s3String, $matches);
-        $bucket = $matches[1];
-        $filePath = '/' . $matches[2];
-        $region = getenv('AWS_REGION');
-
-        $url = self::AWS_S3_PresignDownload(
-            getenv('AWS_ACCESS_KEY_ID'),
-            getenv('AWS_SECRET_ACCESS_KEY'),
-            getenv('AWS_SESSION_TOKEN'),
-            $bucket,
-            $region,
-            $filePath
-        );
-
-        if (file_exists($downloadPath)) {
-            unlink($downloadPath);
-        }
-
-        $fp = fopen($downloadPath, 'w');
-
-        $options = [
-            CURLOPT_HEADER => 0,
-            CURLOPT_FOLLOWLOCATION => 1,
-            CURLOPT_FILE => $fp,
-        ];
-
-        $ch = curl_init($url);
-        curl_setopt_array($ch, $options);
-        curl_exec($ch);
-        curl_close($ch);
-        fclose($fp);
-    }
-
-    /**
-     * Create S3 signed url to download vendor archive from
-     * From https://gist.github.com/anthonyeden/4448695ad531016ec12bcdacc9d91cb8
-     */
-    private static function AWS_S3_PresignDownload(
-        string $AWSAccessKeyId,
-        string $AWSSecretAccessKey,
-        string $AWSSessionToken,
-        string $BucketName,
-        string $AWSRegion,
-        string $canonical_uri,
-        int $expires = 86400
-    ): string {
-        // Creates a signed download link for an AWS S3 file
-        // Based on https://gist.github.com/kelvinmo/d78be66c4f36415a6b80
-
-        $encoded_uri = str_replace('%2F', '/', rawurlencode($canonical_uri));
-
-        // Specify the hostname for the S3 endpoint
-        if ($AWSRegion == 'us-east-1') {
-            $hostname = trim($BucketName . ".s3.amazonaws.com");
-            $header_string = "host:" . $hostname . "\n";
-            $signed_headers_string = "host";
-        } else {
-            $hostname = trim($BucketName . ".s3-" . $AWSRegion . ".amazonaws.com");
-            $header_string = "host:" . $hostname . "\n";
-            $signed_headers_string = "host";
-        }
-
-        $date_text = gmdate('Ymd', time());
-        $time_text = $date_text . 'T000000Z';
-        $algorithm = 'AWS4-HMAC-SHA256';
-        $scope = $date_text . "/" . $AWSRegion . "/s3/aws4_request";
-
-        $x_amz_params = array(
-            'X-Amz-Algorithm' => $algorithm,
-            'X-Amz-Credential' => $AWSAccessKeyId . '/' . $scope,
-            'X-Amz-Date' => $time_text,
-            'X-Amz-SignedHeaders' => $signed_headers_string,
-            'X-Amz-Security-Token' => $AWSSessionToken,
-        );
-
-        if ($expires > 0) {
-            // 'Expires' is the number of seconds until the request becomes invalid
-            $x_amz_params['X-Amz-Expires'] = (string)$expires;
-        }
-
-        ksort($x_amz_params);
-
-        $query_string = "";
-        foreach ($x_amz_params as $key => $value) {
-            $query_string .= rawurlencode($key) . '=' . rawurlencode($value) . "&";
-        }
-        $query_string = substr($query_string, 0, -1);
-
-        $canonical_request = "GET\n" . $encoded_uri . "\n" . $query_string . "\n" . $header_string . "\n" . $signed_headers_string . "\nUNSIGNED-PAYLOAD";
-        $string_to_sign = $algorithm . "\n" . $time_text . "\n" . $scope . "\n" . hash('sha256', $canonical_request,
-                false);
-        $signing_key = hash_hmac('sha256', 'aws4_request',
-            hash_hmac('sha256', 's3',
-                hash_hmac('sha256', $AWSRegion,
-                    hash_hmac('sha256', $date_text, 'AWS4' . $AWSSecretAccessKey, true),
-                    true),
-                true),
-            true
-        );
-        $signature = hash_hmac('sha256', $string_to_sign, $signing_key);
-
-        return 'https://' . $hostname . $encoded_uri . '?' . $query_string . '&X-Amz-Signature=' . $signature;
-    }
-
-    /**
-     * Unzip vendor archive into temporary path
-     */
-    private static function unzipVendorArchive(string $filePath, string $unzipPath): bool
-    {
-        $zip = new ZipArchive();
-        $resource = $zip->open($filePath);
-
-        if (!file_exists($unzipPath)) {
-            mkdir($unzipPath, 0755, true);
-        }
-
-        if ($resource !== true) {
-            return false;
-        }
-
-        $zip->extractTo($unzipPath);
-        $zip->close();
-
-        return true;
-    }
-
-    /**
-     * Update composer autoload_static.php file with correct path for Lambda task root
-     */
-    private static function updateComposerAutoloading(): void
-    {
-        $updatedStaticLoader = str_replace(
-            "__DIR__ . '/../..'",
-            sprintf("'%s'", rtrim(getenv('LAMBDA_TASK_ROOT'), '/')),
-            file_get_contents(self::AUTOLOAD_STATIC_PATH)
-        );
-
-        file_put_contents(self::AUTOLOAD_STATIC_PATH, $updatedStaticLoader);
-    }
-}
diff --git a/runtime/layers/function/php.ini b/runtime/layers/function/php.ini
deleted file mode 100644
index cc6fa53cf..000000000
--- a/runtime/layers/function/php.ini
+++ /dev/null
@@ -1,44 +0,0 @@
-; On the CLI we want errors to be sent to stdout -> those will end up in CloudWatch
-display_errors=1
-
-; Since PHP 7.4 the default value is E_ALL
-; We override it to set the recommended configuration value for production.
-; See https://github.com/php/php-src/blob/d91abf76e01a3c39424e8192ad049f473f900936/php.ini-production#L463
-error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
-
-memory_limit=3008M
-
-opcache.enable=1
-opcache.enable_cli=1
-
-; Store the opcodes into a file cache instead of memory
-; Since PHP runs on lambdas with a new process each time the memory cache is lost
-; TODO store in a subdirectory (but the problem is that the subdirectory doesn't exist when PHP starts...)
-opcache.file_cache="/tmp"
-; Disable the memory cache since it's useless
-; In my tests it allows to gain 30% of response time in a classic API controller
-opcache.file_cache_only=1
-; Skip this check to save a bit
-opcache.validate_permission=0
-
-; The code is readonly on lambdas so it never changes
-; This setting is now disabled: code could be written to /tmp which is read/write
-; (e.g. a compiled container) Such a performance optimization can be done by users.
-;opcache.validate_timestamps=0
-
-; Set sane values, modern PHP applications have higher needs than opcache's defaults
-; See https://tideways.com/profiler/blog/fine-tune-your-opcache-configuration-to-avoid-caching-suprises
-opcache.memory_consumption=128
-opcache.max_accelerated_files=10000
-
-zend_extension=opcache.so
-
-; This directive determines which super global arrays are registered when PHP
-; starts up. G,P,C,E & S are abbreviations for the following respective super
-; globals: GET, POST, COOKIE, ENV and SERVER.
-; We explicitly populate all variables else ENV is not populated by default.
-; See https://github.com/brefphp/bref/pull/291
-variables_order="EGPCS"
-
-; Enable the PDO MySQL extension
-extension=pdo_mysql
diff --git a/runtime/layers/publish.php b/runtime/layers/publish.php
deleted file mode 100644
index 2ab108588..000000000
--- a/runtime/layers/publish.php
+++ /dev/null
@@ -1,147 +0,0 @@
-<?php declare(strict_types=1);
-
-/**
- * This script publishes all the layers in all the regions.
- */
-
-use Symfony\Component\Process\Process;
-
-require_once __DIR__ . '/../../vendor/autoload.php';
-
-$layers = [
-    'php-82' => 'PHP 8.2 for event-driven PHP functions',
-    'php-82-fpm' => 'PHP-FPM 8.2 for web applications',
-    'php-81' => 'PHP 8.1 for event-driven PHP functions',
-    'php-81-fpm' => 'PHP-FPM 8.1 for web applications',
-    'php-80' => 'PHP 8.0 for event-driven PHP functions',
-    'php-80-fpm' => 'PHP-FPM 8.0 for web applications',
-    'php-74' => 'PHP 7.4 for event-driven PHP functions',
-    'php-74-fpm' => 'PHP-FPM 7.4 for web applications',
-    'php-73' => 'PHP 7.3 for event-driven PHP functions',
-    'php-73-fpm' => 'PHP-FPM 7.3 for web applications',
-    'console' => 'Console runtime for PHP applications',
-];
-foreach ($layers as $layer => $layerDescription) {
-    $file = __DIR__ . "/../export/$layer.zip";
-    if (! file_exists($file)) {
-        echo "File $file does not exist: generate the archives first\n";
-        exit(1);
-    }
-}
-
-/**
- * These are the regions on which the layers are published.
- */
-$singleRegion = getenv('PUBLISH_REGION');
-if ($singleRegion) {
-    // Allow to override with a specific region for testing
-    $regions = [$singleRegion];
-} else {
-    $regions = json_decode(file_get_contents(__DIR__ . '/regions.json'), true);
-}
-
-// Publish the layers
-/** @var Process[] $publishingProcesses */
-$publishingProcesses = [];
-foreach ($regions as $region) {
-    foreach ($layers as $layer => $layerDescription) {
-        $publishingProcesses[$region . $layer] = publishLayer($region, $layer, $layerDescription);
-    }
-}
-runProcessesInParallel($publishingProcesses);
-echo "\nAll layers are published, adding permissions now\n";
-
-// Add public permissions on the layers
-/** @var Process[] $permissionProcesses */
-$permissionProcesses = [];
-foreach ($regions as $region) {
-    foreach ($layers as $layer => $layerDescription) {
-        $publishLayer = $publishingProcesses[$region . $layer];
-        $layerVersion = trim($publishLayer->getOutput());
-
-        $permissionProcesses[] = addPublicLayerPermissions($region, $layer, $layerVersion);
-    }
-}
-runProcessesInParallel($permissionProcesses);
-echo "\nDone\n";
-
-function publishLayer(string $region, string $layer, string $layerDescription): Process
-{
-    $file = __DIR__ . "/../export/$layer.zip";
-
-    $process = new Process([
-        'aws',
-        'lambda',
-        'publish-layer-version',
-        '--region',
-        $region,
-        '--layer-name',
-        $layer,
-        '--description',
-        $layerDescription,
-        '--license-info',
-        'MIT',
-        '--zip-file',
-        'fileb://' . $file,
-        '--compatible-runtimes',
-        'provided.al2',
-        // Output the version so that we can fetch it and use it
-        '--output',
-        'text',
-        '--query',
-        'Version',
-    ]);
-    $process->setTimeout(null);
-
-    return $process;
-}
-
-/**
- * @param Process[] $processes
- */
-function runProcessesInParallel(array $processes): void
-{
-    // Run the processes in batches to parallelize them without overloading the machine and the network
-    foreach (array_chunk($processes, 4) as $batch) {
-        // Start all the processes
-        array_map(function (Process $process): void {
-            $process->start();
-        }, $batch);
-        // Wait for them to finish
-        array_map(function (Process $process): void {
-            $status = $process->wait();
-            echo '.';
-            // Make sure the process ran successfully
-            if ($status !== 0) {
-                echo 'Process ' . $process->getCommandLine() . ' failed:' . PHP_EOL;
-                echo $process->getErrorOutput();
-                echo $process->getOutput();
-                exit(1);
-            }
-        }, $batch);
-    }
-}
-
-function addPublicLayerPermissions(string $region, string $layer, string $layerVersion): Process
-{
-    $process = new Process([
-        'aws',
-        'lambda',
-        'add-layer-version-permission',
-        '--region',
-        $region,
-        '--layer-name',
-        $layer,
-        '--version-number',
-        $layerVersion,
-        '--statement-id',
-        'public',
-        '--action',
-        'lambda:GetLayerVersion',
-        '--principal',
-        '*',
-    ]);
-    $process->setTimeout(null);
-
-    return $process;
-}
diff --git a/runtime/layers/web/Dockerfile b/runtime/layers/web/Dockerfile
deleted file mode 100644
index 28987b5ad..000000000
--- a/runtime/layers/web/Dockerfile
+++ /dev/null
@@ -1,8 +0,0 @@
-FROM nginx:alpine
-COPY default.conf /etc/nginx/conf.d/default.conf
-
-CMD  if [ -z "$DOCUMENT_ROOT" ];then HANDLER_DR=$HANDLER;else HANDLER_DR=${HANDLER#"$DOCUMENT_ROOT/"};fi;\
-sed -i "s|##DOCUMENT_ROOT##|$DOCUMENT_ROOT|g" /etc/nginx/conf.d/default.conf\
-;sed -i "s|##HANDLER##|$HANDLER|g" /etc/nginx/conf.d/default.conf\
-;sed -i "s|##HANDLER_DR##|$HANDLER_DR|g" /etc/nginx/conf.d/default.conf\
-&& nginx -g "daemon off;"
diff --git a/runtime/layers/web/default.conf b/runtime/layers/web/default.conf
deleted file mode 100644
index d9ff7fd89..000000000
--- a/runtime/layers/web/default.conf
+++ /dev/null
@@ -1,22 +0,0 @@
-server {
-    server_name php-docker.local;
-    root /var/task/##DOCUMENT_ROOT##;
-    fastcgi_buffers 16 16k;
-    fastcgi_buffer_size 32k;
-
-    client_max_body_size 6M;
-
-    location / {
-        # try to serve file directly, fallback to handler
-        try_files $uri /##HANDLER_DR##$is_args$args;
-    }
-
-    location ~ ^/##HANDLER_DR##(/|$) {
-        fastcgi_split_path_info ^(.+\.php)(/.+)$;
-        fastcgi_pass php:9001;
-        include fastcgi_params;
-        fastcgi_param SCRIPT_FILENAME /var/task/##HANDLER##;
-        fastcgi_param PATH_INFO $fastcgi_path_info;
-        internal;
-    }
-}
diff --git a/runtime/pipeline/Makefile b/runtime/pipeline/Makefile
deleted file mode 100644
index 0a4687de8..000000000
--- a/runtime/pipeline/Makefile
+++ /dev/null
@@ -1,2 +0,0 @@
-deploy-builder:
-	aws cloudformation update-stack --stack-name bref-layers-builder --template-body file://builder.yaml --capabilities CAPABILITY_NAMED_IAM --region us-east-1
diff --git a/runtime/pipeline/builder.yaml b/runtime/pipeline/builder.yaml
deleted file mode 100644
index fec0844d6..000000000
--- a/runtime/pipeline/builder.yaml
+++ /dev/null
@@ -1,172 +0,0 @@
-Description: AWS CodePipeline for building PHP Binary
-
-Resources:
-  SourceCodeStorageBucket:
-    Type: AWS::S3::Bucket
-    Properties:
-      AccessControl: Private
-      LifecycleConfiguration:
-        Rules:
-          - ExpirationInDays: 5
-            Status: Enabled
-
-  GitHubConnection:
-    Type: AWS::CodeStarConnections::Connection
-    Properties:
-      ConnectionName: brefphp_organization
-      ProviderType: GitHub
-
-  CodeBuild:
-    Type: AWS::CodeBuild::Project
-    Properties:
-      Artifacts:
-        Type: CODEPIPELINE
-      Environment:
-        ComputeType: BUILD_GENERAL1_LARGE
-        EnvironmentVariables:
-          - Name: GITHUB_TOKEN
-            Value: '{{resolve:ssm:/bref-layers-builder/github/token:1}}'
-          - Name: DOCKER_HUB_USERNAME
-            Value: '{{resolve:ssm:/bref-layers-builder/docker/hub/username:1}}'
-          - Name: DOCKER_HUB_PASSWORD
-            Value: '{{resolve:ssm:/bref-layers-builder/docker/hub/password:1}}'
-          - Name: DOCKER_BUILDKIT
-            Value: 1
-          - Name: AWS_STS_REGIONAL_ENDPOINTS
-            Value: regional
-        Image: aws/codebuild/amazonlinux2-x86_64-standard:3.0
-        PrivilegedMode: true
-        Type: LINUX_CONTAINER
-      ServiceRole: !Ref CodeBuildRole
-      Source:
-        Type: CODEPIPELINE
-        BuildSpec: runtime/pipeline/buildspec.yaml
-      TimeoutInMinutes: 120
-
-  CodePipeline:
-    Type: AWS::CodePipeline::Pipeline
-    Properties:
-      ArtifactStore:
-        Location: !Ref SourceCodeStorageBucket
-        Type: S3
-      Name: bref-php-binary
-      RestartExecutionOnUpdate: false
-      RoleArn: !GetAtt CodePipelineRole.Arn
-      Stages:
-        - Name: Source
-          Actions:
-            - Name: bref
-              ActionTypeId:
-                Version: 1
-                Owner: AWS
-                Category: Source
-                Provider: CodeStarSourceConnection
-              OutputArtifacts:
-                - Name: src
-              Configuration:
-                ConnectionArn: !Ref GitHubConnection
-                FullRepositoryId: brefphp/bref
-                BranchName: master
-                OutputArtifactFormat: CODEBUILD_CLONE_REF
-                DetectChanges: false
-
-        - Name: Build
-          Actions:
-            - Name: CodeBuild
-              ActionTypeId:
-                Category: Build
-                Owner: AWS
-                Provider: CodeBuild
-                Version: 1
-              Configuration:
-                ProjectName: !Ref CodeBuild
-                PrimarySource: src
-              InputArtifacts:
-                - Name: src
-
-  CodeBuildRole:
-    Type: AWS::IAM::Role
-    Properties:
-      RoleName: bref-php-binary-builder
-      AssumeRolePolicyDocument:
-        Statement:
-          Effect: Allow
-          Principal:
-            Service: codebuild.amazonaws.com
-          Action: sts:AssumeRole
-      Policies:
-        - PolicyName: CodeBuildServicePermissions
-          PolicyDocument:
-            Statement:
-              - Effect: Allow
-                Action:
-                  - codestar-connections:UseConnection
-                Resource:
-                  - !Ref GitHubConnection
-
-              - Effect: Allow
-                Action:
-                  - sts:AssumeRole
-                Resource:
-                  - 'arn:aws:iam::209497400698:role/bref-layer-publisher'
-
-              - Effect: Allow
-                Action:
-                  - s3:ListObjects
-                  - s3:GetObject
-                  - s3:GetObjectVersion
-                Resource:
-                  - !GetAtt SourceCodeStorageBucket.Arn
-                  - !Sub "${SourceCodeStorageBucket.Arn}/*"
-
-              - Effect: Allow
-                Action:
-                  - logs:CreateLogGroup
-                  - logs:CreateLogStream
-                  - logs:PutLogEvents
-                Resource:
-                  - 'arn:aws:logs:*:*:*'
-
-              - Effect: Allow
-                Action:
-                  - lambda:PublishLayerVersion
-                  - lambda:AddLayerVersionPermission
-                Resource:
-                  - '*'
-
-  CodePipelineRole:
-    Type: AWS::IAM::Role
-    Properties:
-      AssumeRolePolicyDocument:
-        Statement:
-          Effect: Allow
-          Principal:
-            Service: codepipeline.amazonaws.com
-          Action: sts:AssumeRole
-      Policies:
-        - PolicyName: CodePipelinePermissions
-          PolicyDocument:
-            Statement:
-              - Effect: Allow
-                Action:
-                  - codestar-connections:UseConnection
-                Resource:
-                  - !Ref GitHubConnection
-
-              - Effect: Allow
-                Action:
-                  - s3:GetObject
-                  - s3:GetObjectVersion
-                  - s3:GetBucketVersioning
-                  - s3:PutObject
-                Resource:
-                  - !GetAtt SourceCodeStorageBucket.Arn
-                  - !Sub "${SourceCodeStorageBucket.Arn}/*"
-
-              - Effect: Allow
-                Action:
-                  - codebuild:BatchGetBuilds
-                  - codebuild:StartBuild
-                Resource:
-                  - !GetAtt CodeBuild.Arn
-
diff --git a/runtime/pipeline/buildspec.yaml b/runtime/pipeline/buildspec.yaml
deleted file mode 100755
index 522a47d47..000000000
--- a/runtime/pipeline/buildspec.yaml
+++ /dev/null
@@ -1,48 +0,0 @@
-version: 0.2
-
-env:
-  git-credential-helper: yes
-
-phases:
-  install:
-    runtime-versions:
-      docker: 19
-      php: 7.3
-
-  build:
-    commands:
-      # Install GH CLI
-      - curl https://cli.github.com/packages/rpm/gh-cli.repo --output /etc/yum.repos.d/gh-cli.repo
-      - yum install -y gh
-
-      # Configure GitHub User & Branch
-      - git config --global user.email "deleugyn+bref@gmail.com"
-      - git config --global user.name "Brefphp Bot by @deleugyn"
-      - git checkout master
-      - git checkout -b bref/layers
-
-      # Login to Docker Hub to avoid rate limit
-      - docker login --username $DOCKER_HUB_USERNAME --password $DOCKER_HUB_PASSWORD
-
-      # Necessary for publish.php to use Symfony Processor
-      - make getcomposer
-      - php composer.phar install
-
-      # Copy AWS config to be able to automatically assume role into the layer account
-      - mkdir ~/.aws && cp ./runtime/pipeline/config ~/.aws/config
-
-      - make runtimes
-
-      - make layers.json
-
-      - |
-        echo -en 'layers.json update\n\nCo-authored-by: Marco Deleu <deleugyn@gmail.com>' > git-message
-
-      - git commit -a --file git-message
-
-      - git remote add github https://brefphp-bot:${GITHUB_TOKEN}@github.com/brefphp-bot/bref.git
-      - git push --set-upstream github bref/layers --force
-
-      - gh pr create --title "New PHP Layers" --body "New layers have finished building" --repo "brefphp/bref" --base master --head "brefphp-bot:bref/layers"
-
-
diff --git a/runtime/pipeline/config b/runtime/pipeline/config
deleted file mode 100644
index 95be7b675..000000000
--- a/runtime/pipeline/config
+++ /dev/null
@@ -1,3 +0,0 @@
-[default]
-role_arn = arn:aws:iam::209497400698:role/bref-layer-publisher
-credential_source = EcsContainer
\ No newline at end of file
diff --git a/runtime/pipeline/publisher.yaml b/runtime/pipeline/publisher.yaml
deleted file mode 100644
index e00393c34..000000000
--- a/runtime/pipeline/publisher.yaml
+++ /dev/null
@@ -1,31 +0,0 @@
-Description: AWS IAM Role for publishing layers
-
-Parameters:
-  BuilderAccount:
-    Type: String
-
-Resources:
-  LayerPublisherRole:
-    Type: AWS::IAM::Role
-    Properties:
-      RoleName: bref-layer-publisher
-      AssumeRolePolicyDocument:
-        Statement:
-          Effect: Allow
-          Principal:
-            AWS: !Sub "arn:aws:iam::${BuilderAccount}:role/bref-php-binary-builder"
-          Action: sts:AssumeRole
-      Policies:
-        - PolicyName: LayerPublisherRole
-          PolicyDocument:
-            Statement:
-              - Effect: Allow
-                Action:
-                  - lambda:PublishLayerVersion
-                  - lambda:AddLayerVersionPermission
-                  - lambda:GetLayerVersion
-                  - lambda:GetLayerVersionPolicy
-                  - lambda:ListLayers
-                  - lambda:ListLayerVersions
-                Resource:
-                  - '*'
diff --git a/serverless.yml b/serverless.yml
index e43b947ad..d7a7fc815 100644
--- a/serverless.yml
+++ b/serverless.yml
@@ -2,7 +2,6 @@ service: bref-demo
 
 provider:
     name: aws
-    runtime: provided.al2
     region: us-east-2
     logs:
         restApi: true
@@ -23,27 +22,25 @@ package:
 functions:
     function:
         handler: demo/function.php
-        description: 'Bref CLI demo'
-        layers:
-            - ${bref:layer.php-82}
+        runtime: php-81
+        description: 'Bref function demo'
         environment:
             BREF_LOOP_MAX: 100
 
     http:
         handler: demo/http.php
+        runtime: php-81-fpm
+        architecture: arm64
         description: 'Bref HTTP demo'
         timeout: 5 # in seconds (API Gateway has a timeout of 29 seconds)
-        layers:
-            - ${bref:layer.php-82-fpm}
         events:
             -   http: 'ANY /'
 
     psr7:
         handler: demo/psr7.php
+        runtime: php-81
         description: 'Bref HTTP demo with a PSR-7 handler'
         timeout: 5 # in seconds (API Gateway has a timeout of 29 seconds)
-        layers:
-            - ${bref:layer.php-82}
         events:
             -   http: 'ANY /psr7'
             -   httpApi: 'GET /psr7'
@@ -52,17 +49,14 @@ functions:
 
     http-api:
         handler: demo/http.php
+        runtime: php-81-fpm
         description: 'Bref HTTP demo'
         timeout: 5 # in seconds (API Gateway has a timeout of 29 seconds)
-        layers:
-            - ${bref:layer.php-82-fpm}
         events:
             -   httpApi: '*'
 
     console:
         handler: demo/console.php
+        runtime: php-81-console
         description: 'Bref console command demo'
-        timeout: 900
-        layers:
-            - ${bref:layer.php-82}
-            - ${bref:layer.console}
+        timeout: 5
diff --git a/src/Bref.php b/src/Bref.php
index aa54228cd..180cdbac6 100644
--- a/src/Bref.php
+++ b/src/Bref.php
@@ -7,22 +7,19 @@
 use Psr\Container\ContainerInterface;
 use RuntimeException;
 
-/**
- * @experimental This class is not covered by backward compatibility yet.
- */
 class Bref
 {
-    /** @var Closure|null */
-    private static $containerProvider;
-    /** @var ContainerInterface|null */
-    private static $container;
+    private static ?Closure $containerProvider = null;
+    private static ?ContainerInterface $container = null;
+    private static array $hooks = [
+        'beforeStartup' => [],
+        'beforeInvoke' => [],
+    ];
 
     /**
      * Configure the container that provides Lambda handlers.
      *
-     * @param Closure $containerProvider Function that must return a `ContainerInterface`.
-     *
-     * @psalm-param Closure(): ContainerInterface $containerProvider
+     * @param Closure(): ContainerInterface $containerProvider Function that must return a `ContainerInterface`.
      */
     public static function setContainer(Closure $containerProvider): void
     {
@@ -30,7 +27,43 @@ public static function setContainer(Closure $containerProvider): void
     }
 
     /**
-     * @internal
+     * Register a hook to be executed before the runtime starts.
+     *
+     * Warning: hooks are low-level extension points to be used by framework
+     * integrations. For user code, it is not recommended to use them. Use your
+     * framework's extension points instead.
+     */
+    public static function beforeStartup(Closure $hook): void
+    {
+        self::$hooks['beforeStartup'][] = $hook;
+    }
+
+    /**
+     * Register a hook to be executed before any Lambda invocation.
+     *
+     * Warning: hooks are low-level extension points to be used by framework
+     * integrations. For user code, it is not recommended to use them. Use your
+     * framework's extension points instead.
+     */
+    public static function beforeInvoke(Closure $hook): void
+    {
+        self::$hooks['beforeInvoke'][] = $hook;
+    }
+
+    /**
+     * @param 'beforeStartup'|'beforeInvoke' $hookName
+     *
+     * @internal Used by the Bref runtime
+     */
+    public static function triggerHooks(string $hookName): void
+    {
+        foreach (self::$hooks[$hookName] as $hook) {
+            $hook();
+        }
+    }
+
+    /**
+     * @internal Used by the Bref runtime
      */
     public static function getContainer(): ContainerInterface
     {
diff --git a/src/Cli/init.php b/src/Cli/init.php
new file mode 100644
index 000000000..992694ded
--- /dev/null
+++ b/src/Cli/init.php
@@ -0,0 +1,85 @@
+<?php declare(strict_types=1);
+
+use Symfony\Component\Process\ExecutableFinder;
+use Symfony\Component\Process\Process;
+
+function init(): void
+{
+    $exeFinder = new ExecutableFinder;
+    if (! $exeFinder->find('serverless')) {
+        warning(
+            'The `serverless` command is not installed.' . PHP_EOL .
+            'You will not be able to deploy your application unless it is installed' . PHP_EOL .
+            'Please follow the instructions at https://bref.sh/docs/installation.html' . PHP_EOL .
+            'If you have the `serverless` command available elsewhere (eg in a Docker container) you can ignore this warning.' . PHP_EOL
+        );
+    }
+
+    $intro = green('What kind of application do you want to create?');
+    echo <<<TEXT
+    $intro (you will be able to add more functions later by editing `serverless.yml`)
+      [0] Web application (default)
+      [1] Event-driven function
+
+    TEXT;
+    $choice = readline('> ') ?: '0';
+    echo PHP_EOL;
+    if (! in_array($choice, ['0', '1'], true)) {
+        error('Invalid response (must be "0" or "1"), aborting');
+    }
+
+    $templateDirectory = [
+        '0' => 'http',
+        '1' => 'function',
+    ][$choice];
+
+    $rootPath = dirname(__DIR__, 2) . "/template/$templateDirectory";
+
+    createFile($rootPath, 'index.php');
+    createFile($rootPath, 'serverless.yml');
+
+    success('Project initialized and ready to test or deploy.');
+}
+
+/**
+ * Creates files from the template directory and automatically adds them to git
+ */
+function createFile(string $templatePath, string $file): void
+{
+    echo "Creating $file\n";
+
+    if (file_exists($file)) {
+        $overwrite = false;
+        echo "A file named $file already exists, do you want to overwrite it? [y/N]\n";
+        $choice = strtolower(readline('> ') ?: 'n');
+        echo PHP_EOL;
+        if ($choice === 'y') {
+            $overwrite = true;
+        } elseif (! in_array($choice, ['y', 'n'], true)) {
+            error('Invalid response (must be "y" or "n"), aborting');
+        }
+        if (! $overwrite) {
+            echo "Skipping $file\n";
+            return;
+        }
+    }
+
+    $template = file_get_contents("$templatePath/$file");
+    if (! $template) {
+        error("Could not read file $templatePath/$file");
+    }
+    $template = str_replace('PHP_VERSION', PHP_MAJOR_VERSION . PHP_MINOR_VERSION, $template);
+    file_put_contents($file, $template);
+
+    /*
+     * We check if this is a git repository to automatically add file to git.
+     */
+    $message = "$file successfully created";
+    if ((new Process(['git', 'rev-parse', '--is-inside-work-tree']))->run() === 0) {
+        (new Process(['git', 'add', $file]))->run();
+        $message .= ' and added to git automatically';
+    }
+
+    echo PHP_EOL;
+    success("$message.");
+}
diff --git a/src/Console/Command/Cli.php b/src/Console/Command/Cli.php
deleted file mode 100644
index 79624a844..000000000
--- a/src/Console/Command/Cli.php
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Console\Command;
-
-use Bref\Lambda\InvocationFailed;
-use Bref\Lambda\SimpleLambdaClient;
-use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputArgument;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Style\SymfonyStyle;
-
-final class Cli extends Command
-{
-    protected function configure(): void
-    {
-        $this
-            ->setName('cli')
-            ->setDescription('Runs a CLI command in the remote environment')
-            ->addArgument('function', InputArgument::REQUIRED)
-            ->addArgument('arguments', InputArgument::IS_ARRAY)
-            ->addOption('region', 'r', InputOption::VALUE_REQUIRED)
-            ->addOption('profile', 'p', InputOption::VALUE_REQUIRED);
-    }
-
-    protected function execute(InputInterface $input, OutputInterface $output): int
-    {
-        $io = new SymfonyStyle($input, $output);
-        $function = $input->getArgument('function');
-        $arguments = $input->getArgument('arguments');
-        $region = $input->getOption('region');
-        $profile = $input->getOption('profile');
-
-        $lambda = new SimpleLambdaClient(
-            $region ?: getenv('AWS_DEFAULT_REGION') ?: 'us-east-1',
-            $profile ?: getenv('AWS_PROFILE') ?: 'default',
-            15 * 60 // maximum duration on Lambda
-        );
-
-        // Because arguments may contain spaces, and are going to be executed remotely
-        // as a separate process, we need to escape all arguments.
-        $arguments = array_map(static function (string $arg): string {
-            return escapeshellarg($arg);
-        }, $arguments);
-
-        try {
-            $result = $lambda->invoke($function, json_encode(implode(' ', $arguments)));
-        } catch (InvocationFailed $e) {
-            $io->getErrorStyle()->writeln('<info>' . $e->getInvocationLogs() . '</info>');
-            $io->error($e->getMessage());
-            return 1;
-        }
-
-        $payload = $result->getPayload();
-        if (isset($payload['output'])) {
-            $io->writeln($payload['output']);
-        } else {
-            $io->error('The command did not return a valid response.');
-            $io->writeln('<info>Logs:</info>');
-            $io->write('<comment>' . $result->getLogs() . '</comment>');
-            $io->writeln('<info>Lambda result payload:</info>');
-            $io->writeln(json_encode($payload, JSON_PRETTY_PRINT));
-            return 1;
-        }
-
-        return (int) ($payload['exitCode'] ?? 1);
-    }
-}
diff --git a/src/Console/Command/Dashboard.php b/src/Console/Command/Dashboard.php
deleted file mode 100644
index 75f5d6e85..000000000
--- a/src/Console/Command/Dashboard.php
+++ /dev/null
@@ -1,149 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Console\Command;
-
-use Bref\Console\LoadingAnimation;
-use Bref\Console\OpenUrl;
-use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Style\SymfonyStyle;
-use Symfony\Component\Process\ExecutableFinder;
-use Symfony\Component\Process\Process;
-
-final class Dashboard extends Command
-{
-    protected function configure(): void
-    {
-        $this
-            ->setName('dashboard')
-            ->setDescription('Starts the dashboard')
-            ->addOption('host', null, InputOption::VALUE_REQUIRED, '', 'localhost')
-            ->addOption('port', null, InputOption::VALUE_REQUIRED, '', '8000')
-            ->addOption('profile', null, InputOption::VALUE_REQUIRED)
-            ->addOption('stage', null, InputOption::VALUE_REQUIRED);
-    }
-
-    protected function execute(InputInterface $input, OutputInterface $output): int
-    {
-        $io = new SymfonyStyle($input, $output);
-        $host = $input->getOption('host');
-        $port = (int) $input->getOption('port');
-        $profile = $input->getOption('profile');
-        $stage = $input->getOption('stage');
-
-        $io->info('The Bref Dashboard is also available as an application: https://dashboard.bref.sh');
-        if ($host === 'localhost') {
-            $host = '127.0.0.1';
-        }
-        if ($profile === null) {
-            $profile = getenv('AWS_PROFILE') ?: 'default';
-        }
-
-        if (! file_exists('serverless.yml')) {
-            $io->error('No `serverless.yml` file found.');
-
-            return 1;
-        }
-
-        $exeFinder = new ExecutableFinder;
-        if (! $exeFinder->find('docker')) {
-            $io->error(
-                'The `docker` command is not installed.' . PHP_EOL .
-                'Please follow the instructions at https://docs.docker.com/install/'
-            );
-
-            return 1;
-        }
-
-        if (! $exeFinder->find('serverless')) {
-            $io->error(
-                'The `serverless` command is not installed.' . PHP_EOL .
-                'Please follow the instructions at https://bref.sh/docs/installation.html'
-            );
-
-            return 1;
-        }
-
-        $args = ['serverless', 'info', '--aws-profile', $profile];
-        if ($stage) {
-            $args[] = '--stage';
-            $args[] = $stage;
-        }
-        $serverlessInfo = new Process($args);
-        $serverlessInfo->start();
-        $animation = new LoadingAnimation($io);
-        do {
-            $animation->tick('Retrieving the stack');
-            usleep(100 * 1000);
-        } while ($serverlessInfo->isRunning());
-        $animation->clear();
-
-        if (! $serverlessInfo->isSuccessful()) {
-            $io->error('The command `serverless info` failed' . PHP_EOL . $serverlessInfo->getOutput());
-
-            return 1;
-        }
-
-        $serverlessInfoOutput = $serverlessInfo->getOutput();
-
-        $region = [];
-        preg_match('/region: ([a-z0-9-]*)/', $serverlessInfoOutput, $region);
-        $region = $region[1];
-
-        $stack = [];
-        preg_match('/stack: ([a-zA-Z0-9-]*)/', $serverlessInfoOutput, $stack);
-        $stack = $stack[1];
-
-        $io->writeln("Stack: <fg=yellow>$stack ($region)</>");
-
-        $dockerPull = new Process(['docker', 'pull', 'bref/dashboard']);
-        $dockerPull->setTimeout(null);
-        $dockerPull->start();
-        do {
-            $animation->tick('Retrieving the latest version of the dashboard');
-            usleep(100 * 1000);
-        } while ($dockerPull->isRunning());
-        $animation->clear();
-        if (! $dockerPull->isSuccessful()) {
-            $io->error([
-                'The command `docker pull bref/dashboard` failed',
-                $dockerPull->getErrorOutput(),
-            ]);
-
-            return 1;
-        }
-
-        $process = new Process(['docker', 'run', '--rm', '-p', $host . ':' . $port . ':8000', '-v', getenv('HOME') . '/.aws:/root/.aws:ro', '--env', 'STACKNAME=' . $stack, '--env', 'REGION=' . $region, '--env', 'AWS_PROFILE=' . $profile, 'bref/dashboard']);
-        $process->setTimeout(null);
-        $process->start();
-        do {
-            $animation->tick('Starting the dashboard');
-            usleep(100 * 1000);
-            $serverOutput = $process->getOutput() . $process->getErrorOutput();
-            $hasStarted = (strpos($serverOutput, 'Development Server') !== false);
-        } while ($process->isRunning() && ! $hasStarted);
-        $animation->clear();
-        if (! $process->isRunning()) {
-            $io->error([
-                'The dashboard failed to start',
-                $process->getErrorOutput(),
-            ]);
-
-            return 1;
-        }
-        $url = "http://$host:$port";
-        $io->writeln("Dashboard started: <fg=green;options=bold,underscore>$url</>");
-        OpenUrl::open($url);
-        $process->wait(function ($type, $buffer): void {
-            if ($type === Process::ERR) {
-                echo 'ERR > ' . $buffer;
-            } else {
-                echo 'OUT > ' . $buffer;
-            }
-        });
-
-        return $process->getExitCode();
-    }
-}
diff --git a/src/Console/Command/Init.php b/src/Console/Command/Init.php
deleted file mode 100644
index 88117373a..000000000
--- a/src/Console/Command/Init.php
+++ /dev/null
@@ -1,103 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Console\Command;
-
-use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Style\SymfonyStyle;
-use Symfony\Component\Process\ExecutableFinder;
-use Symfony\Component\Process\Process;
-
-final class Init extends Command
-{
-    /**
-     * Symfony console input/output handler
-     *
-     * @var SymfonyStyle
-     */
-    private $io;
-
-    /**
-     * Absolute path pointing into project's
-     * template directory
-     *
-     * @var string
-     */
-    private $rootPath;
-
-    protected function configure(): void
-    {
-        $this->setName('init');
-    }
-
-    protected function execute(InputInterface $input, OutputInterface $output): int
-    {
-        $this->io = $io = new SymfonyStyle($input, $output);
-
-        $exeFinder = new ExecutableFinder;
-        if (! $exeFinder->find('serverless')) {
-            $io->warning(
-                'The `serverless` command is not installed.' . PHP_EOL .
-                'You will not be able to deploy your application unless it is installed' . PHP_EOL .
-                'Please follow the instructions at https://bref.sh/docs/installation.html' . PHP_EOL .
-                'If you have the `serverless` command available elsewhere (eg in a Docker container) you can ignore this warning'
-            );
-        }
-
-        $choice = $io->choice(
-            'What kind of lambda do you want to create? (you will be able to add more functions later by editing `serverless.yml`)',
-            [
-                'Web application',
-                'Event-driven function',
-            ],
-            'Web application',
-        );
-
-        $templateDirectory = [
-            'Web application' => 'http',
-            'Event-driven function' => 'function',
-        ][$choice];
-
-        $this->rootPath = dirname(__DIR__, 3) . "/template/$templateDirectory";
-
-        self::createFile('index.php');
-        self::createFile('serverless.yml');
-
-        $io->success('Project initialized and ready to test or deploy.');
-
-        return 0;
-    }
-
-    /**
-     * Creates files from the template directory and automatically adds
-     * them to git
-     */
-    private function createFile(string $file): void
-    {
-        $overwrite = true;
-
-        $this->io->writeln("Creating $file");
-
-        if (file_exists($file)) {
-            $overwrite = $this->io->confirm("A file named $file already exists, do you want to overwrite it?", true);
-        }
-
-        if ($overwrite) {
-            $template = file_get_contents("$this->rootPath/$file");
-            $template = str_replace('PHP_VERSION', PHP_MAJOR_VERSION . PHP_MINOR_VERSION, $template);
-            file_put_contents($file, $template);
-
-            /*
-             * We check if this is a git repository to automatically add file to git.
-             */
-            $message = "$file successfully created";
-            if ((new Process(['git', 'rev-parse', '--is-inside-work-tree']))->run() === 0) {
-                (new Process(['git', 'add', $file]))->run();
-                $message .= ' and added to git automatically';
-            }
-
-            $this->io->success("$message.");
-        }
-    }
-}
diff --git a/src/Console/Command/Layers.php b/src/Console/Command/Layers.php
deleted file mode 100644
index 9eda89d36..000000000
--- a/src/Console/Command/Layers.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Console\Command;
-
-use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputArgument;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Style\SymfonyStyle;
-
-final class Layers extends Command
-{
-    protected function configure(): void
-    {
-        $this
-            ->setName('layers')
-            ->setDescription('Displays the versions of the Bref layers')
-            ->addArgument('region', InputArgument::REQUIRED);
-    }
-
-    protected function execute(InputInterface $input, OutputInterface $output): int
-    {
-        $io = new SymfonyStyle($input, $output);
-        $region = $input->getArgument('region');
-
-        $layersJson = file_get_contents(dirname(__DIR__, 3) . '/layers.json');
-        $layers = json_decode($layersJson, true, 512, JSON_THROW_ON_ERROR);
-        $io->title("Layers for the $region region");
-
-        $array = [];
-        foreach ($layers as $layer => $versions) {
-            $version = $versions[$region];
-            $array[] = [
-                $layer,
-                $version,
-                "arn:aws:lambda:$region:416566615250:layer:$layer:$version",
-            ];
-        }
-        $io->table([
-            'Layer',
-            'Version',
-            'ARN',
-        ], $array);
-
-        return 0;
-    }
-}
diff --git a/src/Console/Command/Local.php b/src/Console/Command/Local.php
deleted file mode 100644
index 3e8b251f1..000000000
--- a/src/Console/Command/Local.php
+++ /dev/null
@@ -1,148 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Console\Command;
-
-use Bref\Bref;
-use Bref\Context\Context;
-use Bref\Runtime\Invoker;
-use Exception;
-use JsonException;
-use Psr\Container\NotFoundExceptionInterface;
-use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputArgument;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Style\SymfonyStyle;
-use Symfony\Component\Yaml\Yaml;
-use Throwable;
-
-/**
- * Local function invocation.
- */
-class Local extends Command
-{
-    protected function configure(): void
-    {
-        $this
-            ->setName('local')
-            ->addArgument('function', InputArgument::OPTIONAL)
-            ->addArgument('data', InputArgument::OPTIONAL)
-            ->addOption('file', 'f', InputOption::VALUE_REQUIRED)
-            ->addOption('handler', null, InputOption::VALUE_REQUIRED)
-            ->addOption('config', 'c', InputOption::VALUE_REQUIRED);
-    }
-
-    protected function execute(InputInterface $input, OutputInterface $output): int
-    {
-        $io = new SymfonyStyle($input, $output);
-        $function = $input->getArgument('function');
-        $data = $input->getArgument('data');
-        $file = $input->getOption('file');
-        $handler = $input->getOption('handler');
-        $config = $input->getOption('config');
-
-        if ($function === null && $handler === null) {
-            throw new Exception('Please provide a function name or the --handler= option.');
-        }
-        if ($function && $data && $handler) {
-            throw new Exception('You cannot provide both a function name and the --handler= option.');
-        }
-
-        if ($config !== null && ! file_exists($config)) {
-            throw new Exception("The serverless file '$config' does not exist.");
-        }
-
-        if ($handler) {
-            // Shift the arguments since there is no function passed
-            $data = $function;
-        } else {
-            $handler = $this->handlerFromServerlessYml($function, $config);
-        }
-
-        if ($data && $file) {
-            throw new Exception('You cannot provide both event data and the --file= option.');
-        }
-
-        try {
-            $handler = Bref::getContainer()->get($handler);
-        } catch (NotFoundExceptionInterface $e) {
-            throw new Exception($e->getMessage() . PHP_EOL . 'Reminder: `bref local` can invoke event-driven functions that use the FUNCTION runtime, not the web app (or "FPM") runtime. Check out https://bref.sh/docs/web-apps/local-development.html to run web applications locally.');
-        }
-
-        if ($file) {
-            if (! file_exists($file)) {
-                throw new Exception("The file '$file' does not exist.");
-            }
-            $data = file_get_contents($file);
-        }
-
-        try {
-            $event = $data ? json_decode($data, true, 512, JSON_THROW_ON_ERROR) : null;
-        } catch (JsonException $e) {
-            throw new Exception('The JSON provided for the event data is invalid JSON.');
-        }
-
-        // Same configuration as the Bref runtime on Lambda
-        ini_set('display_errors', '1');
-        error_reporting(E_ALL);
-
-        $requestId = '8f507cfc-example-4697-b07a-ac58fc914c95';
-        $startTime = $this->logStart($io, $requestId);
-
-        try {
-            $invoker = new Invoker;
-            $result = $invoker->invoke($handler, $event, new Context($requestId, 0, '', ''));
-        } catch (Throwable $e) {
-            $io->writeln([
-                get_class($e) . ': ' . $e->getMessage(),
-                'Stack trace:',
-                $e->getTraceAsString(),
-            ]);
-            $io->error($e->getMessage());
-            return 1;
-        }
-
-        $this->logEnd($startTime, $io, $requestId);
-        // Show the invocation result
-        $io->block(json_encode($result, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), null, 'fg=black;bg=green', '', true);
-
-        return 0;
-    }
-
-    private function handlerFromServerlessYml(string $function, ?string $config): string
-    {
-        $file = $config ?? 'serverless.yml';
-        if (! file_exists($file)) {
-            throw new Exception("No `serverless.yml` file was found to resolve function $function.\nIf you do not use serverless.yml, pass the handler via the `--handler` option: vendor/bin/bref local --handler=file.php\nIf your serverless.yml file is stored elsewhere, use the `--config` option: vendor/bin/bref local --config=foo/serverless.yml");
-        }
-
-        $serverlessConfig = Yaml::parseFile($file, Yaml::PARSE_CUSTOM_TAGS);
-
-        if (! isset($serverlessConfig['functions'][$function])) {
-            throw new Exception("There is no function named '$function' in serverless.yml");
-        }
-        if (! isset($serverlessConfig['functions'][$function]['handler'])) {
-            throw new Exception("There is no handler defined on function '$function' in serverless.yml");
-        }
-
-        return $serverlessConfig['functions'][$function]['handler'];
-    }
-
-    private function logStart(SymfonyStyle $io, string $requestId): float
-    {
-        $io->writeln("START RequestId: $requestId Version: \$LATEST");
-        return microtime(true);
-    }
-
-    private function logEnd(float $startTime, SymfonyStyle $io, string $requestId): void
-    {
-        $duration = ceil((microtime(true) - $startTime) * 1000);
-        $memoryUsed = ceil(memory_get_usage() / 1024 / 1024);
-
-        $io->writeln([
-            "END RequestId: $requestId",
-            "REPORT RequestId: $requestId Duration: $duration ms Billed Duration: $duration ms Memory Size: 1024 MB Max Memory Used: $memoryUsed MB",
-        ]);
-    }
-}
diff --git a/src/Console/LoadingAnimation.php b/src/Console/LoadingAnimation.php
deleted file mode 100644
index 78caf482c..000000000
--- a/src/Console/LoadingAnimation.php
+++ /dev/null
@@ -1,51 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Console;
-
-use Symfony\Component\Console\Output\OutputInterface;
-
-class LoadingAnimation
-{
-    private const CHARACTERS = ['⠇', '⠋', '⠙', '⠸', '⠴', '⠦'];
-
-    /** @var int */
-    private $counter = 0;
-    /** @var OutputInterface */
-    private $output;
-    /** @var int */
-    private $lineLength = 0;
-
-    public function __construct(OutputInterface $output)
-    {
-        $this->output = $output;
-    }
-
-    /**
-     * Update the animation to the next character.
-     */
-    public function tick(string $message): void
-    {
-        $i = $this->counter % count(self::CHARACTERS);
-        $symbol = self::CHARACTERS[$i];
-
-        $this->clear();
-        $this->output->write("<fg=red>$symbol</> $message");
-        // We store the line length to clear it properly later
-        $this->lineLength = strlen($symbol) + 1 + strlen($message);
-
-        $this->counter++;
-    }
-
-    /**
-     * Clear the line.
-     */
-    public function clear(): void
-    {
-        // Move the cursor back at the beginning of the line
-        $this->output->write("\r");
-        // Erase the previously written characters (using spaces)
-        $this->output->write(str_pad(' ', $this->lineLength));
-        // Move the cursor back at the beginning of the line
-        $this->output->write("\r");
-    }
-}
diff --git a/src/Console/OpenUrl.php b/src/Console/OpenUrl.php
deleted file mode 100644
index 5975c5495..000000000
--- a/src/Console/OpenUrl.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Console;
-
-use Symfony\Component\Process\ExecutableFinder;
-use Symfony\Component\Process\Process;
-
-class OpenUrl
-{
-    public static function open(string $url): void
-    {
-        $exeFinder = new ExecutableFinder;
-        if ($exeFinder->find('open')) {
-            // MacOS
-            $process = new Process(['open', $url]);
-            $process->run();
-        } elseif ($exeFinder->find('start')) {
-            // Windows
-            $process = new Process(['start', $url]);
-            $process->run();
-        } elseif ($exeFinder->find('xdg-open')) {
-            // Linux
-            $process = new Process(['xdg-open', $url]);
-            $process->run();
-        }
-    }
-}
diff --git a/src/ConsoleRuntime/Main.php b/src/ConsoleRuntime/Main.php
new file mode 100755
index 000000000..d42435b55
--- /dev/null
+++ b/src/ConsoleRuntime/Main.php
@@ -0,0 +1,66 @@
+<?php declare(strict_types=1);
+
+namespace Bref\ConsoleRuntime;
+
+use Bref\Bref;
+use Bref\Context\Context;
+use Bref\LazySecretsLoader;
+use Bref\Runtime\LambdaRuntime;
+use Exception;
+use Symfony\Component\Process\Process;
+
+/**
+ * @internal
+ */
+class Main
+{
+    public static function run(): void
+    {
+        LazySecretsLoader::loadSecretEnvironmentVariables();
+
+        Bref::triggerHooks('beforeStartup');
+
+        $lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('console');
+
+        $appRoot = getenv('LAMBDA_TASK_ROOT');
+        $handlerFile = $appRoot . '/' . getenv('_HANDLER');
+        if (! is_file($handlerFile)) {
+            $lambdaRuntime->failInitialization("Handler `$handlerFile` doesn't exist");
+        }
+
+        /** @phpstan-ignore-next-line */
+        while (true) {
+            $lambdaRuntime->processNextEvent(function ($event, Context $context) use ($handlerFile): array {
+                if (is_array($event)) {
+                    // Backward compatibility with the former CLI invocation format
+                    $cliOptions = $event['cli'] ?? '';
+                } elseif (is_string($event)) {
+                    $cliOptions = $event;
+                } else {
+                    $cliOptions = '';
+                }
+
+                $timeout = max(1, $context->getRemainingTimeInMillis() / 1000 - 1);
+                $command = sprintf('/opt/bin/php %s %s 2>&1', $handlerFile, $cliOptions);
+                $process = Process::fromShellCommandline($command, null, [
+                    'LAMBDA_INVOCATION_CONTEXT' => json_encode($context, JSON_THROW_ON_ERROR),
+                ], null, $timeout);
+
+                $process->run(function ($type, $buffer): void {
+                    echo $buffer;
+                });
+
+                $exitCode = $process->getExitCode();
+
+                if ($exitCode > 0) {
+                    throw new Exception('The command exited with a non-zero status code: ' . $exitCode);
+                }
+
+                return [
+                    'exitCode' => $exitCode, // will always be 0
+                    'output' => $process->getOutput(),
+                ];
+            });
+        }
+    }
+}
diff --git a/src/Context/Context.php b/src/Context/Context.php
index 4e306ca65..19164caa5 100644
--- a/src/Context/Context.php
+++ b/src/Context/Context.php
@@ -2,31 +2,37 @@
 
 namespace Bref\Context;
 
+use JsonSerializable;
+
 /**
  * The execution context of a Lambda.
  *
  * @see https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html
  */
-final class Context implements \JsonSerializable
+final class Context implements JsonSerializable
 {
-    /** @var string */
-    private $awsRequestId;
-
-    /** @var int Holds the deadline Unix timestamp in millis */
-    private $deadlineMs;
-
-    /** @var string */
-    private $invokedFunctionArn;
-
-    /** @var string */
-    private $traceId;
+    /**
+     * @param int $deadlineMs Holds the deadline Unix timestamp in millis
+     */
+    public function __construct(
+        private string $awsRequestId,
+        private int $deadlineMs,
+        private string $invokedFunctionArn,
+        private string $traceId
+    ) {
+    }
 
-    public function __construct(string $awsRequestId, int $deadlineMs, string $invokedFunctionArn, string $traceId)
+    /**
+     * Test helper to create a fake context in one line.
+     */
+    public static function fake(): self
     {
-        $this->awsRequestId = $awsRequestId;
-        $this->deadlineMs = $deadlineMs;
-        $this->invokedFunctionArn = $invokedFunctionArn;
-        $this->traceId = $traceId;
+        return new self(
+            'fake-aws-request-id',
+            time() + 1000 * 60 * 5, // 5 minutes from now (in milliseconds)
+            'fake-invoked-function-arn',
+            'fake-trace-id'
+        );
     }
 
     /**
diff --git a/src/Context/ContextBuilder.php b/src/Context/ContextBuilder.php
index 69f2998a7..e75f3da23 100644
--- a/src/Context/ContextBuilder.php
+++ b/src/Context/ContextBuilder.php
@@ -2,22 +2,12 @@
 
 namespace Bref\Context;
 
-/**
- * @internal
- */
 final class ContextBuilder
 {
-    /** @var string */
-    private $awsRequestId;
-
-    /** @var int */
-    private $deadlineMs;
-
-    /** @var string */
-    private $invokedFunctionArn;
-
-    /** @var string */
-    private $traceId;
+    private string $awsRequestId;
+    private int $deadlineMs;
+    private string $invokedFunctionArn;
+    private string $traceId;
 
     public function __construct()
     {
diff --git a/src/Event/ApiGateway/WebsocketEvent.php b/src/Event/ApiGateway/WebsocketEvent.php
index e0154e2d1..55c62cea3 100644
--- a/src/Event/ApiGateway/WebsocketEvent.php
+++ b/src/Event/ApiGateway/WebsocketEvent.php
@@ -7,38 +7,24 @@
 
 /**
  * Represents a Lambda event when Lambda is invoked by ApiGateway websocket route.
+ *
+ * @final
  */
-final class WebsocketEvent implements LambdaEvent
+class WebsocketEvent implements LambdaEvent
 {
-    /** @var array */
-    private $event;
-
-    /** @var string */
-    private $routeKey;
-
-    /** @var string|null */
-    private $eventType = null;
-
-    /** @var mixed|null */
-    private $body = null;
-
-    /** @var string */
-    private $connectionId;
-
-    /** @var string */
-    private $domainName;
-
-    /** @var string */
-    private $apiId;
-
-    /** @var string */
-    private $stage;
+    private array $event;
+    private string $routeKey;
+    private string | null $eventType = null;
+    private mixed $body = null;
+    private string $connectionId;
+    private string $domainName;
+    private string $apiId;
+    private string $stage;
 
     /**
-     * @param mixed $event
      * @throws InvalidLambdaEvent
      */
-    public function __construct($event)
+    public function __construct(mixed $event)
     {
         if (
             ! is_array($event) ||
@@ -92,10 +78,7 @@ public function getEventType(): ?string
         return $this->eventType;
     }
 
-    /**
-     * @return mixed|null
-     */
-    public function getBody()
+    public function getBody(): mixed
     {
         return $this->body;
     }
diff --git a/src/Event/DynamoDb/DynamoDbEvent.php b/src/Event/DynamoDb/DynamoDbEvent.php
index c4c714a1c..4a0d15a8c 100644
--- a/src/Event/DynamoDb/DynamoDbEvent.php
+++ b/src/Event/DynamoDb/DynamoDbEvent.php
@@ -4,20 +4,21 @@
 
 use Bref\Event\InvalidLambdaEvent;
 use Bref\Event\LambdaEvent;
+use InvalidArgumentException;
 
 /**
  * Represents a Lambda event when Lambda is invoked by DynamoDB Streams.
+ *
+ * @final
  */
-final class DynamoDbEvent implements LambdaEvent
+class DynamoDbEvent implements LambdaEvent
 {
-    /** @var array */
-    private $event;
+    private array $event;
 
     /**
-     * @param mixed $event
      * @throws InvalidLambdaEvent
      */
-    public function __construct($event)
+    public function __construct(mixed $event)
     {
         if (! is_array($event) || ! isset($event['Records'])) {
             throw new InvalidLambdaEvent('DynamoDB', $event);
@@ -35,7 +36,7 @@ public function getRecords(): array
             function ($record): DynamoDbRecord {
                 try {
                     return new DynamoDbRecord($record);
-                } catch (\InvalidArgumentException $e) {
+                } catch (InvalidArgumentException) {
                     throw new InvalidLambdaEvent('DynamoDb', $this->event);
                 }
             },
diff --git a/src/Event/DynamoDb/DynamoDbRecord.php b/src/Event/DynamoDb/DynamoDbRecord.php
index e32512c06..b5c2b6cb0 100644
--- a/src/Event/DynamoDb/DynamoDbRecord.php
+++ b/src/Event/DynamoDb/DynamoDbRecord.php
@@ -2,18 +2,19 @@
 
 namespace Bref\Event\DynamoDb;
 
-final class DynamoDbRecord
+use InvalidArgumentException;
+
+/**
+ * @final
+ */
+class DynamoDbRecord
 {
-    /** @var array */
-    private $record;
+    private array $record;
 
-    /**
-     * @param mixed $record
-     */
-    public function __construct($record)
+    public function __construct(mixed $record)
     {
         if (! is_array($record) || ! isset($record['eventSource']) || $record['eventSource'] !== 'aws:dynamodb') {
-            throw new \InvalidArgumentException('Event source must come from DynamoDB');
+            throw new InvalidArgumentException('Event source must come from DynamoDB');
         }
 
         $this->record = $record;
diff --git a/src/Event/EventBridge/EventBridgeEvent.php b/src/Event/EventBridge/EventBridgeEvent.php
index 0842c1849..dde573a8e 100644
--- a/src/Event/EventBridge/EventBridgeEvent.php
+++ b/src/Event/EventBridge/EventBridgeEvent.php
@@ -8,16 +8,14 @@
 
 /**
  * Represents a Lambda event when Lambda is invoked by EventBridge.
+ *
+ * @final
  */
-final class EventBridgeEvent implements LambdaEvent
+class EventBridgeEvent implements LambdaEvent
 {
-    /** @var array */
-    private $event;
+    private array $event;
 
-    /**
-     * @param mixed $event
-     */
-    public function __construct($event)
+    public function __construct(mixed $event)
     {
         if (! is_array($event) || ! isset($event['detail-type'])) {
             throw new InvalidLambdaEvent('EventBridge', $event);
@@ -68,10 +66,8 @@ public function getDetailType(): string
      * However, this method will not return a JSON string: it will return the decoded content.
      * This is how EventBridge works: we publish a message with JSON-encoded data. EventBridge decodes it
      * and triggers listeners with the decoded data.
-     *
-     * @return mixed
      */
-    public function getDetail()
+    public function getDetail(): mixed
     {
         return $this->event['detail'];
     }
diff --git a/src/Event/Handler.php b/src/Event/Handler.php
index d7062db77..ecd16a2d6 100644
--- a/src/Event/Handler.php
+++ b/src/Event/Handler.php
@@ -13,5 +13,5 @@ interface Handler
      * @param mixed $event The raw event data.
      * @return mixed|void
      */
-    public function handle($event, Context $context);
+    public function handle(mixed $event, Context $context);
 }
diff --git a/src/Event/Http/HttpRequestEvent.php b/src/Event/Http/HttpRequestEvent.php
index 43362d2e4..8c0b89c67 100644
--- a/src/Event/Http/HttpRequestEvent.php
+++ b/src/Event/Http/HttpRequestEvent.php
@@ -5,6 +5,8 @@
 use Bref\Event\InvalidLambdaEvent;
 use Bref\Event\LambdaEvent;
 
+use function str_starts_with;
+
 /**
  * Represents a Lambda event that comes from a HTTP request.
  *
@@ -15,21 +17,13 @@
  */
 final class HttpRequestEvent implements LambdaEvent
 {
-    /** @var array */
-    private $event;
-    /** @var string */
-    private $method;
-    /** @var array */
-    private $headers;
-    /** @var string */
-    private $queryString;
-    /** @var float */
-    private $payloadVersion;
+    private array $event;
+    private string $method;
+    private array $headers;
+    private string $queryString;
+    private float $payloadVersion;
 
-    /**
-     * @param mixed $event
-     */
-    public function __construct($event)
+    public function __construct(mixed $event)
     {
         // version 1.0 of the HTTP payload
         if (isset($event['httpMethod'])) {
@@ -107,7 +101,7 @@ public function getBasicAuthCredentials(): array
     {
         $authorizationHeader = trim($this->headers['authorization'][0] ?? '');
 
-        if (\strpos($authorizationHeader, 'Basic ') !== 0) {
+        if (! str_starts_with($authorizationHeader, 'Basic ')) {
             return [null, null];
         }
 
@@ -233,14 +227,10 @@ private function rebuildQueryString(): string
         // it would lead to a breaking change since the current implementation for API Gateway does not
         // support MultiValue query string. This way, the code is fully backward-compatible while
         // offering complete support for multi value query parameters on ALB. Later on there can
-        // be a feature flag that allows API Gateway users to opt-in to complete support as well.
-        if (isset($this->event['requestContext']) && isset($this->event['requestContext']['elb'])) {
+        // be a feature flag that allows API Gateway users to opt in to complete support as well.
+        if (isset($this->event['requestContext']['elb'])) {
             // AWS differs between ALB with multiValue enabled or not (docs: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers)
-            if (isset($this->event['multiValueQueryStringParameters'])) {
-                $queryParameters = $this->event['multiValueQueryStringParameters'];
-            } else {
-                $queryParameters = $this->event['queryStringParameters'] ?? [];
-            }
+            $queryParameters = $this->event['multiValueQueryStringParameters'] ?? $this->event['queryStringParameters'] ?? [];
 
             $queryString = '';
 
diff --git a/src/Event/Http/HttpResponse.php b/src/Event/Http/HttpResponse.php
index 8a8286af2..7129f9073 100644
--- a/src/Event/Http/HttpResponse.php
+++ b/src/Event/Http/HttpResponse.php
@@ -7,14 +7,9 @@
  */
 final class HttpResponse
 {
-    /** @var int */
-    private $statusCode;
-
-    /** @var array */
-    private $headers;
-
-    /** @var string */
-    private $body;
+    private int $statusCode;
+    private array $headers;
+    private string $body;
 
     /**
      * @param array<string|string[]> $headers
diff --git a/src/Event/Http/Psr15Handler.php b/src/Event/Http/Psr15Handler.php
index 58328b86e..79ea99e1e 100644
--- a/src/Event/Http/Psr15Handler.php
+++ b/src/Event/Http/Psr15Handler.php
@@ -7,8 +7,7 @@
 
 class Psr15Handler extends HttpHandler
 {
-    /** @var RequestHandlerInterface */
-    private $psr15Handler;
+    private RequestHandlerInterface $psr15Handler;
 
     public function __construct(RequestHandlerInterface $psr15Handler)
     {
diff --git a/src/Event/Http/Psr7Bridge.php b/src/Event/Http/Psr7Bridge.php
index 5c10def18..f6503951e 100644
--- a/src/Event/Http/Psr7Bridge.php
+++ b/src/Event/Http/Psr7Bridge.php
@@ -11,10 +11,10 @@
 use Riverline\MultiPartParser\Part;
 use RuntimeException;
 
+use function str_starts_with;
+
 /**
  * Bridges PSR-7 requests and responses with API Gateway or ALB event/response formats.
- *
- * @internal
  */
 final class Psr7Bridge
 {
@@ -98,7 +98,7 @@ private static function parseBodyAndUploadedFiles(HttpRequestEvent $event): arra
         $parsedBody = null;
         $contentType = $event->getContentType();
         if ($contentType !== null && $event->getMethod() === 'POST') {
-            if (strpos($contentType, 'application/x-www-form-urlencoded') === 0) {
+            if (str_starts_with($contentType, 'application/x-www-form-urlencoded')) {
                 parse_str($bodyString, $parsedBody);
             } else {
                 $document = new Part("Content-type: $contentType\r\n\r\n" . $bodyString);
@@ -126,12 +126,10 @@ private static function parseBodyAndUploadedFiles(HttpRequestEvent $event): arra
 
     /**
      * Parse a string key like "files[id_cards][jpg][]" and do $array['files']['id_cards']['jpg'][] = $value
-     *
-     * @param mixed $value
      */
-    private static function parseKeyAndInsertValueInArray(array &$array, string $key, $value): void
+    private static function parseKeyAndInsertValueInArray(array &$array, string $key, mixed $value): void
     {
-        if (strpos($key, '[') === false) {
+        if (! str_contains($key, '[')) {
             $array[$key] = $value;
 
             return;
@@ -150,7 +148,7 @@ private static function parseKeyAndInsertValueInArray(array &$array, string $key
             // Skip two special cases:
             // [[ in the key produces empty string
             // [test : starts with [ but does not end with ]
-            if ($part === '' || substr($part, -1) !== ']') {
+            if ($part === '' || ! str_ends_with($part, ']')) {
                 // Malformed key, we use it "as is"
                 $array[$key] = $value;
 
diff --git a/src/Event/InvalidLambdaEvent.php b/src/Event/InvalidLambdaEvent.php
index ffccf3c4c..ee63a2977 100644
--- a/src/Event/InvalidLambdaEvent.php
+++ b/src/Event/InvalidLambdaEvent.php
@@ -6,10 +6,7 @@
 
 final class InvalidLambdaEvent extends Exception
 {
-    /**
-     * @param mixed $event
-     */
-    public function __construct(string $expectedEventType, $event)
+    public function __construct(string $expectedEventType, mixed $event)
     {
         if (! $event) {
             $eventData = 'null';
diff --git a/src/Event/Kafka/KafkaEvent.php b/src/Event/Kafka/KafkaEvent.php
index 7d9822a9c..99615eb1f 100644
--- a/src/Event/Kafka/KafkaEvent.php
+++ b/src/Event/Kafka/KafkaEvent.php
@@ -6,10 +6,12 @@
 use Bref\Event\InvalidLambdaEvent;
 use InvalidArgumentException;
 
-final class KafkaEvent implements Event\LambdaEvent
+/**
+ * @final
+ */
+class KafkaEvent implements Event\LambdaEvent
 {
-    /** @var array */
-    private $event;
+    private array $event;
 
     /**
      * Represents a Lambda event when Lambda is invoked by Kafka.
diff --git a/src/Event/Kafka/KafkaHandler.php b/src/Event/Kafka/KafkaHandler.php
index 764694ff4..d748a36c0 100644
--- a/src/Event/Kafka/KafkaHandler.php
+++ b/src/Event/Kafka/KafkaHandler.php
@@ -10,7 +10,7 @@ abstract class KafkaHandler implements Handler
     abstract public function handleKafka(KafkaEvent $event, Context $context): void;
 
     /** {@inheritDoc} */
-    final public function handle($event, Context $context)
+    public function handle($event, Context $context)
     {
         $this->handleKafka(new KafkaEvent($event), $context);
     }
diff --git a/src/Event/Kafka/KafkaRecord.php b/src/Event/Kafka/KafkaRecord.php
index 094f12b4a..4c97c3d1a 100644
--- a/src/Event/Kafka/KafkaRecord.php
+++ b/src/Event/Kafka/KafkaRecord.php
@@ -4,15 +4,14 @@
 
 use InvalidArgumentException;
 
-final class KafkaRecord
+/**
+ * @final
+ */
+class KafkaRecord
 {
-    /** @var array */
-    private $record;
+    private array $record;
 
-    /**
-     * @param mixed $record
-     */
-    public function __construct($record)
+    public function __construct(mixed $record)
     {
         if (! is_array($record)) {
             throw new InvalidArgumentException;
@@ -40,16 +39,13 @@ public function getTimestamp(): int
         return $this->record['timestamp'];
     }
 
-    /**
-     * @return mixed
-     */
-    public function getValue()
+    public function getValue(): mixed
     {
         return base64_decode($this->record['value']);
     }
 
     /**
-     * A header in Kafka is an objects, with a single property. The name of the property is the name of the header. It's value is a
+     * A header in Kafka is an objects, with a single property. The name of the property is the name of the header. Its value is a
      * byte-array, representing a string. We'll normalize it to a hashmap with key and value being strings.
      *
      * @return array<string, string>
diff --git a/src/Event/Kinesis/KinesisEvent.php b/src/Event/Kinesis/KinesisEvent.php
index 8da5f5316..cfd035740 100644
--- a/src/Event/Kinesis/KinesisEvent.php
+++ b/src/Event/Kinesis/KinesisEvent.php
@@ -4,17 +4,19 @@
 
 use Bref\Event\InvalidLambdaEvent;
 use Bref\Event\LambdaEvent;
+use InvalidArgumentException;
 
-final class KinesisEvent implements LambdaEvent
+/**
+ * @final
+ */
+class KinesisEvent implements LambdaEvent
 {
-    /** @var array */
-    private $event;
+    private array $event;
 
     /**
-     * @param mixed $event
      * @throws InvalidLambdaEvent
      */
-    public function __construct($event)
+    public function __construct(mixed $event)
     {
         if (! is_array($event) || ! isset($event['Records'])) {
             throw new InvalidLambdaEvent('Kinesis', $event);
@@ -32,7 +34,7 @@ public function getRecords(): array
             function ($record): KinesisRecord {
                 try {
                     return new KinesisRecord($record);
-                } catch (\InvalidArgumentException $e) {
+                } catch (InvalidArgumentException) {
                     throw new InvalidLambdaEvent('Kinesis', $this->event);
                 }
             },
diff --git a/src/Event/Kinesis/KinesisRecord.php b/src/Event/Kinesis/KinesisRecord.php
index 084302443..ca45a1513 100644
--- a/src/Event/Kinesis/KinesisRecord.php
+++ b/src/Event/Kinesis/KinesisRecord.php
@@ -3,19 +3,19 @@
 namespace Bref\Event\Kinesis;
 
 use DateTimeImmutable;
+use InvalidArgumentException;
 
-final class KinesisRecord
+/**
+ * @final
+ */
+class KinesisRecord
 {
-    /** @var array */
-    private $record;
+    private array $record;
 
-    /**
-     * @param mixed $record
-     */
-    public function __construct($record)
+    public function __construct(mixed $record)
     {
         if (! is_array($record) || ! isset($record['eventSource']) || $record['eventSource'] !== 'aws:kinesis') {
-            throw new \InvalidArgumentException('Event source must come from Kinesis');
+            throw new InvalidArgumentException('Event source must come from Kinesis');
         }
 
         $this->record = $record;
@@ -28,7 +28,7 @@ public function getApproximateArrivalTime(): DateTimeImmutable
 
     public function getData(): array
     {
-        return json_decode(base64_decode($this->getRawData()), true);
+        return json_decode(base64_decode($this->getRawData()), true, 512, JSON_THROW_ON_ERROR);
     }
 
     public function getEventName(): string
diff --git a/src/Event/S3/Bucket.php b/src/Event/S3/Bucket.php
index d2dcf03c6..abf6fc965 100644
--- a/src/Event/S3/Bucket.php
+++ b/src/Event/S3/Bucket.php
@@ -7,14 +7,9 @@
  */
 final class Bucket
 {
-    /** @var string */
-    private $name;
-    /** @var string */
-    private $arn;
+    private string $name;
+    private string $arn;
 
-    /**
-     * @internal
-     */
     public function __construct(string $name, string $arn)
     {
         $this->name = $name;
diff --git a/src/Event/S3/BucketObject.php b/src/Event/S3/BucketObject.php
index dd0e647d4..24a62b052 100644
--- a/src/Event/S3/BucketObject.php
+++ b/src/Event/S3/BucketObject.php
@@ -7,16 +7,10 @@
  */
 final class BucketObject
 {
-    /** @var string */
-    private $key;
-    /** @var int */
-    private $size;
-    /** @var string|null */
-    private $versionId;
+    private string $key;
+    private int $size;
+    private ?string $versionId;
 
-    /**
-     * @internal
-     */
     public function __construct(string $key, int $size, ?string $versionId = null)
     {
         $this->key = $key;
diff --git a/src/Event/S3/S3Event.php b/src/Event/S3/S3Event.php
index 06bad7859..672dfdd6a 100644
--- a/src/Event/S3/S3Event.php
+++ b/src/Event/S3/S3Event.php
@@ -10,18 +10,14 @@
  * Represents an event when Lambda is invoked by S3.
  *
  * @see https://docs.aws.amazon.com/AmazonS3/latest/dev/notification-content-structure.html
+ *
+ * @final
  */
-final class S3Event implements LambdaEvent
+class S3Event implements LambdaEvent
 {
-    /** @var array */
-    private $event;
+    private array $event;
 
-    /**
-     * @param mixed $event
-     *
-     * @internal
-     */
-    public function __construct($event)
+    public function __construct(mixed $event)
     {
         if (! is_array($event) || ! isset($event['Records'])) {
             throw new InvalidLambdaEvent('S3', $event);
@@ -37,7 +33,7 @@ public function getRecords(): array
         return array_map(function ($record): S3Record {
             try {
                 return new S3Record($record);
-            } catch (InvalidArgumentException $e) {
+            } catch (InvalidArgumentException) {
                 throw new InvalidLambdaEvent('S3', $this->event);
             }
         }, $this->event['Records']);
diff --git a/src/Event/S3/S3Record.php b/src/Event/S3/S3Record.php
index 68b67759f..e558ee910 100644
--- a/src/Event/S3/S3Record.php
+++ b/src/Event/S3/S3Record.php
@@ -7,18 +7,14 @@
 
 /**
  * @see https://docs.aws.amazon.com/AmazonS3/latest/dev/notification-content-structure.html
+ *
+ * @final
  */
-final class S3Record
+class S3Record
 {
-    /** @var array */
-    private $record;
+    private array $record;
 
-    /**
-     * @param mixed $record
-     *
-     * @internal
-     */
-    public function __construct($record)
+    public function __construct(mixed $record)
     {
         if (! is_array($record) || ! isset($record['eventSource']) || $record['eventSource'] !== 'aws:s3') {
             throw new InvalidArgumentException;
diff --git a/src/Event/Sns/MessageAttribute.php b/src/Event/Sns/MessageAttribute.php
index 972b227ac..26dac3ff4 100644
--- a/src/Event/Sns/MessageAttribute.php
+++ b/src/Event/Sns/MessageAttribute.php
@@ -10,8 +10,7 @@
  */
 class MessageAttribute
 {
-    /** @var array */
-    private $attribute;
+    private array $attribute;
 
     public function __construct(array $attribute)
     {
diff --git a/src/Event/Sns/SnsEvent.php b/src/Event/Sns/SnsEvent.php
index f62730ff7..5ed239517 100644
--- a/src/Event/Sns/SnsEvent.php
+++ b/src/Event/Sns/SnsEvent.php
@@ -8,16 +8,14 @@
 
 /**
  * Represents a Lambda event when Lambda is invoked by SNS.
+ *
+ * @final
  */
-final class SnsEvent implements LambdaEvent
+class SnsEvent implements LambdaEvent
 {
-    /** @var array */
-    private $event;
+    private array $event;
 
-    /**
-     * @param mixed $event
-     */
-    public function __construct($event)
+    public function __construct(mixed $event)
     {
         if (! is_array($event) || ! isset($event['Records'])) {
             throw new InvalidLambdaEvent('SNS', $event);
@@ -33,7 +31,7 @@ public function getRecords(): array
         return array_map(function ($record): SnsRecord {
             try {
                 return new SnsRecord($record);
-            } catch (InvalidArgumentException $e) {
+            } catch (InvalidArgumentException) {
                 throw new InvalidLambdaEvent('SNS', $this->event);
             }
         }, $this->event['Records']);
diff --git a/src/Event/Sns/SnsRecord.php b/src/Event/Sns/SnsRecord.php
index 36d1d01c9..919715ea8 100644
--- a/src/Event/Sns/SnsRecord.php
+++ b/src/Event/Sns/SnsRecord.php
@@ -9,16 +9,14 @@
  * Represents a SNS message record.
  *
  * For more information about each field, see https://docs.aws.amazon.com/sns/latest/api/API_Publish.html
+ *
+ * @final
  */
-final class SnsRecord
+class SnsRecord
 {
-    /** @var array */
-    private $record;
+    private array $record;
 
-    /**
-     * @param mixed $record
-     */
-    public function __construct($record)
+    public function __construct(mixed $record)
     {
         if (! is_array($record) || ! isset($record['EventSource']) || $record['EventSource'] !== 'aws:sns') {
             throw new InvalidArgumentException;
diff --git a/src/Event/Sqs/SqsEvent.php b/src/Event/Sqs/SqsEvent.php
index 63c62dce2..1ae2c0d6e 100644
--- a/src/Event/Sqs/SqsEvent.php
+++ b/src/Event/Sqs/SqsEvent.php
@@ -8,16 +8,14 @@
 
 /**
  * Represents a Lambda event when Lambda is invoked by SQS.
+ *
+ * @final
  */
-final class SqsEvent implements LambdaEvent
+class SqsEvent implements LambdaEvent
 {
-    /** @var array */
-    private $event;
+    private array $event;
 
-    /**
-     * @param mixed $event
-     */
-    public function __construct($event)
+    public function __construct(mixed $event)
     {
         if (! is_array($event) || ! isset($event['Records'])) {
             throw new InvalidLambdaEvent('SQS', $event);
@@ -33,7 +31,7 @@ public function getRecords(): array
         return array_map(function ($record): SqsRecord {
             try {
                 return new SqsRecord($record);
-            } catch (InvalidArgumentException $e) {
+            } catch (InvalidArgumentException) {
                 throw new InvalidLambdaEvent('SQS', $this->event);
             }
         }, $this->event['Records']);
diff --git a/src/Event/Sqs/SqsHandler.php b/src/Event/Sqs/SqsHandler.php
index ad2cc0fae..d96bcca01 100644
--- a/src/Event/Sqs/SqsHandler.php
+++ b/src/Event/Sqs/SqsHandler.php
@@ -11,12 +11,12 @@
 abstract class SqsHandler implements Handler
 {
     /** @var SqsRecord[] */
-    private $failedRecords = [];
+    private array $failedRecords = [];
 
     abstract public function handleSqs(SqsEvent $event, Context $context): void;
 
     /** {@inheritDoc} */
-    public function handle($event, Context $context)
+    public function handle($event, Context $context): array
     {
         // Reset the failed records to clear the internal state when using BREF_LOOP_MAX
         $this->failedRecords = [];
@@ -24,7 +24,7 @@ public function handle($event, Context $context)
         $this->handleSqs(new SqsEvent($event), $context);
 
         if (count($this->failedRecords) === 0) {
-            return;
+            return [];
         }
 
         $failures = array_map(
diff --git a/src/Event/Sqs/SqsRecord.php b/src/Event/Sqs/SqsRecord.php
index ee7a29674..4c9db8058 100644
--- a/src/Event/Sqs/SqsRecord.php
+++ b/src/Event/Sqs/SqsRecord.php
@@ -4,15 +4,14 @@
 
 use InvalidArgumentException;
 
-final class SqsRecord
+/**
+ * @final
+ */
+class SqsRecord
 {
-    /** @var array */
-    private $record;
+    private array $record;
 
-    /**
-     * @param mixed $record
-     */
-    public function __construct($record)
+    public function __construct(mixed $record)
     {
         if (! is_array($record) || ! isset($record['eventSource']) || $record['eventSource'] !== 'aws:sqs') {
             throw new InvalidArgumentException;
diff --git a/src/Event/Http/FastCgi/FastCgiCommunicationFailed.php b/src/FpmRuntime/FastCgi/FastCgiCommunicationFailed.php
similarity index 83%
rename from src/Event/Http/FastCgi/FastCgiCommunicationFailed.php
rename to src/FpmRuntime/FastCgi/FastCgiCommunicationFailed.php
index e2e92fdc2..68aacfaa8 100644
--- a/src/Event/Http/FastCgi/FastCgiCommunicationFailed.php
+++ b/src/FpmRuntime/FastCgi/FastCgiCommunicationFailed.php
@@ -1,6 +1,6 @@
 <?php declare(strict_types=1);
 
-namespace Bref\Event\Http\FastCgi;
+namespace Bref\FpmRuntime\FastCgi;
 
 /**
  * There was an error while communicating with FastCGI.
diff --git a/src/Event/Http/FastCgi/FastCgiRequest.php b/src/FpmRuntime/FastCgi/FastCgiRequest.php
similarity index 70%
rename from src/Event/Http/FastCgi/FastCgiRequest.php
rename to src/FpmRuntime/FastCgi/FastCgiRequest.php
index 5c1a4e325..b081a5314 100644
--- a/src/Event/Http/FastCgi/FastCgiRequest.php
+++ b/src/FpmRuntime/FastCgi/FastCgiRequest.php
@@ -1,6 +1,6 @@
 <?php declare(strict_types=1);
 
-namespace Bref\Event\Http\FastCgi;
+namespace Bref\FpmRuntime\FastCgi;
 
 use hollodotme\FastCGI\Requests\AbstractRequest;
 
@@ -9,11 +9,11 @@
  */
 final class FastCgiRequest extends AbstractRequest
 {
-    /** @var string */
-    private $method;
-
-    public function __construct(string $method, string $scriptFilename, string $content)
-    {
+    public function __construct(
+        private string $method,
+        string $scriptFilename,
+        string $content
+    ) {
         $this->method = $method;
         parent::__construct($scriptFilename, $content);
     }
diff --git a/src/Event/Http/FastCgi/Timeout.php b/src/FpmRuntime/FastCgi/Timeout.php
similarity index 94%
rename from src/Event/Http/FastCgi/Timeout.php
rename to src/FpmRuntime/FastCgi/Timeout.php
index 0cf998477..b37ca389d 100644
--- a/src/Event/Http/FastCgi/Timeout.php
+++ b/src/FpmRuntime/FastCgi/Timeout.php
@@ -1,6 +1,6 @@
 <?php declare(strict_types=1);
 
-namespace Bref\Event\Http\FastCgi;
+namespace Bref\FpmRuntime\FastCgi;
 
 /**
  * There was a timeout while processing the PHP request
diff --git a/src/Event/Http/FpmHandler.php b/src/FpmRuntime/FpmHandler.php
similarity index 91%
rename from src/Event/Http/FpmHandler.php
rename to src/FpmRuntime/FpmHandler.php
index 14ee1d288..a63c3a467 100644
--- a/src/Event/Http/FpmHandler.php
+++ b/src/FpmRuntime/FpmHandler.php
@@ -1,11 +1,14 @@
 <?php declare(strict_types=1);
 
-namespace Bref\Event\Http;
+namespace Bref\FpmRuntime;
 
 use Bref\Context\Context;
-use Bref\Event\Http\FastCgi\FastCgiCommunicationFailed;
-use Bref\Event\Http\FastCgi\FastCgiRequest;
-use Bref\Event\Http\FastCgi\Timeout;
+use Bref\Event\Http\HttpHandler;
+use Bref\Event\Http\HttpRequestEvent;
+use Bref\Event\Http\HttpResponse;
+use Bref\FpmRuntime\FastCgi\FastCgiCommunicationFailed;
+use Bref\FpmRuntime\FastCgi\FastCgiRequest;
+use Bref\FpmRuntime\FastCgi\Timeout;
 use Exception;
 use hollodotme\FastCGI\Client;
 use hollodotme\FastCGI\Exceptions\TimedoutException;
@@ -21,7 +24,7 @@
  * Usage example:
  *
  *     $event = [get the Lambda event];
- *     $phpFpm = new PhpFpm('index.php');
+ *     $phpFpm = new FpmHandler('index.php');
  *     $phpFpm->start();
  *     $lambdaResponse = $phpFpm->handle($event);
  *     $phpFpm->stop();
@@ -40,16 +43,11 @@ final class FpmHandler extends HttpHandler
      */
     private const SIGTERM = 15;
 
-    /** @var Client|null */
-    private $client;
-    /** @var UnixDomainSocket */
-    private $connection;
-    /** @var string */
-    private $handler;
-    /** @var string */
-    private $configFile;
-    /** @var Process|null */
-    private $fpm;
+    private ?Client $client;
+    private UnixDomainSocket $connection;
+    private string $handler;
+    private string $configFile;
+    private ?Process $fpm = null;
 
     public function __construct(string $handler, string $configFile = self::CONFIG)
     {
@@ -59,6 +57,8 @@ public function __construct(string $handler, string $configFile = self::CONFIG)
 
     /**
      * Start the PHP-FPM process.
+     *
+     * @throws Exception
      */
     public function start(): void
     {
@@ -90,6 +90,9 @@ public function start(): void
         $this->waitUntilReady();
     }
 
+    /**
+     * @throws Exception
+     */
     public function stop(): void
     {
         if ($this->fpm && $this->fpm->isRunning()) {
@@ -104,6 +107,9 @@ public function stop(): void
         }
     }
 
+    /**
+     * @throws Exception
+     */
     public function __destruct()
     {
         $this->stop();
@@ -111,6 +117,10 @@ public function __destruct()
 
     /**
      * Proxy the API Gateway event to PHP-FPM and return its response.
+     *
+     * @throws FastCgiCommunicationFailed
+     * @throws Timeout
+     * @throws Exception
      */
     public function handleRequest(HttpRequestEvent $event, Context $context): HttpResponse
     {
@@ -125,7 +135,7 @@ public function handleRequest(HttpRequestEvent $event, Context $context): HttpRe
             $socketId = $this->client->sendAsyncRequest($this->connection, $request);
 
             $response = $this->client->readResponse($socketId, $timeoutDelayInMs);
-        } catch (TimedoutException $e) {
+        } catch (TimedoutException) {
             echo "The PHP script timed out. Bref will now restart PHP-FPM to start from a clean slate and flush the PHP logs.\nTimeouts can happen for example when trying to connect to a remote API or database, if this happens continuously check for those.\nIf you are using a RDS database, read this: https://bref.sh/docs/environment/database.html#accessing-the-internet\n";
 
             /**
@@ -186,6 +196,9 @@ private function ensureStillRunning(): void
         }
     }
 
+    /**
+     * @throws Exception
+     */
     private function waitUntilReady(): void
     {
         $wait = 5000; // 5ms
@@ -226,8 +239,8 @@ private function eventToFastCgiRequest(HttpRequestEvent $event, Context $context
         $request->setServerPort($event->getServerPort());
         $request->setCustomVar('PATH_INFO', $event->getPath());
         $request->setCustomVar('QUERY_STRING', $event->getQueryString());
-        $request->setCustomVar('LAMBDA_INVOCATION_CONTEXT', json_encode($context));
-        $request->setCustomVar('LAMBDA_REQUEST_CONTEXT', json_encode($event->getRequestContext()));
+        $request->setCustomVar('LAMBDA_INVOCATION_CONTEXT', json_encode($context, JSON_THROW_ON_ERROR));
+        $request->setCustomVar('LAMBDA_REQUEST_CONTEXT', json_encode($event->getRequestContext(), JSON_THROW_ON_ERROR));
 
         $contentType = $event->getContentType();
         if ($contentType) {
@@ -244,7 +257,9 @@ private function eventToFastCgiRequest(HttpRequestEvent $event, Context $context
     }
 
     /**
-     * This methods makes sure to kill any existing PHP-FPM process.
+     * This method makes sure to kill any existing PHP-FPM process.
+     *
+     * @throws Exception
      */
     private function killExistingFpm(): void
     {
@@ -297,6 +312,8 @@ private function killExistingFpm(): void
 
     /**
      * Wait until PHP-FPM has stopped.
+     *
+     * @throws Exception
      */
     private function waitUntilStopped(int $pid): void
     {
diff --git a/src/FpmRuntime/Main.php b/src/FpmRuntime/Main.php
new file mode 100755
index 000000000..6c8aa842b
--- /dev/null
+++ b/src/FpmRuntime/Main.php
@@ -0,0 +1,45 @@
+<?php declare(strict_types=1);
+
+namespace Bref\FpmRuntime;
+
+use Bref\Bref;
+use Bref\LazySecretsLoader;
+use Bref\Runtime\LambdaRuntime;
+use Throwable;
+
+/**
+ * @internal
+ */
+class Main
+{
+    public static function run(): void
+    {
+        // In the FPM runtime process (our process) we want to log all errors and warnings
+        ini_set('display_errors', '1');
+        error_reporting(E_ALL);
+
+        LazySecretsLoader::loadSecretEnvironmentVariables();
+
+        Bref::triggerHooks('beforeStartup');
+
+        $lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('fpm');
+
+        $appRoot = getenv('LAMBDA_TASK_ROOT');
+        $handlerFile = $appRoot . '/' . getenv('_HANDLER');
+        if (! is_file($handlerFile)) {
+            $lambdaRuntime->failInitialization("Handler `$handlerFile` doesn't exist");
+        }
+
+        $phpFpm = new FpmHandler($handlerFile);
+        try {
+            $phpFpm->start();
+        } catch (Throwable $e) {
+            $lambdaRuntime->failInitialization('Error while starting PHP-FPM', $e);
+        }
+
+        /** @phpstan-ignore-next-line */
+        while (true) {
+            $lambdaRuntime->processNextEvent($phpFpm);
+        }
+    }
+}
diff --git a/src/FunctionRuntime/Main.php b/src/FunctionRuntime/Main.php
new file mode 100644
index 000000000..c4843eda4
--- /dev/null
+++ b/src/FunctionRuntime/Main.php
@@ -0,0 +1,45 @@
+<?php declare(strict_types=1);
+
+namespace Bref\FunctionRuntime;
+
+use Bref\Bref;
+use Bref\LazySecretsLoader;
+use Bref\Runtime\LambdaRuntime;
+use Throwable;
+
+/**
+ * @internal
+ */
+class Main
+{
+    public static function run(): void
+    {
+        LazySecretsLoader::loadSecretEnvironmentVariables();
+
+        Bref::triggerHooks('beforeStartup');
+
+        $lambdaRuntime = LambdaRuntime::fromEnvironmentVariable('function');
+
+        $container = Bref::getContainer();
+
+        try {
+            $handler = $container->get(getenv('_HANDLER'));
+        } catch (Throwable $e) {
+            $lambdaRuntime->failInitialization($e->getMessage());
+        }
+
+        $loopMax = getenv('BREF_LOOP_MAX') ?: 1;
+        $loops = 0;
+        while (true) {
+            if (++$loops > $loopMax) {
+                exit(0);
+            }
+            $success = $lambdaRuntime->processNextEvent($handler);
+            // In case the execution failed, we force starting a new process regardless of BREF_LOOP_MAX
+            // Why: an exception could have left the application in a non-clean state, this is preventive
+            if (! $success) {
+                exit(0);
+            }
+        }
+    }
+}
diff --git a/src/Lambda/InvocationFailed.php b/src/Lambda/InvocationFailed.php
deleted file mode 100644
index b39211ccd..000000000
--- a/src/Lambda/InvocationFailed.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Lambda;
-
-final class InvocationFailed extends \Exception
-{
-    /** @var InvocationResult */
-    private $invocationResult;
-
-    public function __construct(InvocationResult $invocationResult)
-    {
-        $this->invocationResult = $invocationResult;
-        $message = $invocationResult->getPayload()['errorMessage'] ?? 'Unknown error';
-
-        parent::__construct($message);
-    }
-
-    public function getInvocationLogs(): string
-    {
-        return $this->invocationResult->getLogs();
-    }
-}
diff --git a/src/Lambda/InvocationResult.php b/src/Lambda/InvocationResult.php
deleted file mode 100644
index 47072abe3..000000000
--- a/src/Lambda/InvocationResult.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Lambda;
-
-use AsyncAws\Lambda\Result\InvocationResponse;
-
-/**
- * The result of a successful lambda invocation.
- */
-final class InvocationResult
-{
-    /** @var InvocationResponse */
-    private $result;
-
-    /** @var mixed */
-    private $payload;
-
-    /**
-     * @param mixed $payload
-     */
-    public function __construct(InvocationResponse $result, $payload)
-    {
-        $this->result = $result;
-        $this->payload = $payload;
-    }
-
-    public function getLogs(): string
-    {
-        return base64_decode($this->result->getLogResult());
-    }
-
-    /**
-     * @return mixed
-     */
-    public function getPayload()
-    {
-        return $this->payload;
-    }
-}
diff --git a/src/Lambda/SimpleLambdaClient.php b/src/Lambda/SimpleLambdaClient.php
deleted file mode 100644
index cfb81e364..000000000
--- a/src/Lambda/SimpleLambdaClient.php
+++ /dev/null
@@ -1,54 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Lambda;
-
-use AsyncAws\Lambda\LambdaClient;
-use Symfony\Component\HttpClient\HttpClient;
-
-/**
- * A simpler alternative to the official LambdaClient from the AWS SDK.
- */
-final class SimpleLambdaClient
-{
-    /** @var LambdaClient */
-    private $lambda;
-
-    public function __construct(string $region, string $profile, int $timeout = 60)
-    {
-        $this->lambda = new LambdaClient(
-            [
-                'region' => $region,
-                'profile' => $profile,
-            ],
-            null,
-            HttpClient::create([
-                'timeout' => $timeout,
-            ])
-        );
-    }
-
-    /**
-     * Synchronously invoke a function.
-     *
-     * @param mixed $event Event data (can be null).
-     * @throws InvocationFailed
-     */
-    public function invoke(string $functionName, $event = null): InvocationResult
-    {
-        $rawResult = $this->lambda->invoke([
-            'FunctionName' => $functionName,
-            'LogType' => 'Tail',
-            'Payload' => $event ?? '',
-        ]);
-
-        $resultPayload = json_decode($rawResult->getPayload(), true);
-        $invocationResult = new InvocationResult($rawResult, $resultPayload);
-
-        $error = $rawResult->getFunctionError();
-        if ($error) {
-            throw new InvocationFailed($invocationResult);
-        }
-
-        return $invocationResult;
-    }
-}
diff --git a/src/LazySecretsLoader.php b/src/LazySecretsLoader.php
new file mode 100644
index 000000000..cc3f2b42a
--- /dev/null
+++ b/src/LazySecretsLoader.php
@@ -0,0 +1,51 @@
+<?php declare(strict_types=1);
+
+namespace Bref;
+
+use Bref\Secrets\Secrets;
+use Exception;
+
+/**
+ * Checks environment variables for the "bref-ssm:xxx" syntax.
+ *
+ * If found, it ensures the "bref/secrets-loader" package is installed
+ * and then loads the secrets.
+ *
+ * That lets us make the "bref/secrets-loader" package an optional dependency.
+ *
+ * @internal
+ */
+final class LazySecretsLoader
+{
+    public static function loadSecretEnvironmentVariables(): void
+    {
+        if (! self::areThereSecretsToLoad()) {
+            return;
+        }
+
+        if (! class_exists(Secrets::class)) {
+            throw new Exception('The "bref/secrets-loader" package is required to load SSM parameters via the "bref-ssm:xxx" syntax in environment variables. Please add it to your "require" section in composer.json.');
+        }
+
+        Secrets::loadSecretEnvironmentVariables();
+    }
+
+    /**
+     * Checks if there are any environment variable that starts with "bref-ssm:".
+     */
+    private static function areThereSecretsToLoad(): bool
+    {
+        /** @var array<string,string>|string|false $envVars */
+        $envVars = getenv(local_only: true); // @phpstan-ignore-line PHPStan is wrong
+        if (! is_array($envVars)) {
+            return false;
+        }
+
+        // Only consider environment variables that start with "bref-ssm:"
+        $envVarsToDecrypt = array_filter($envVars, function (string $value): bool {
+            return str_starts_with($value, 'bref-ssm:');
+        });
+
+        return ! empty($envVarsToDecrypt);
+    }
+}
diff --git a/src/Runtime/FileHandlerLocator.php b/src/Runtime/FileHandlerLocator.php
index dc4ff957e..59b0f2e87 100644
--- a/src/Runtime/FileHandlerLocator.php
+++ b/src/Runtime/FileHandlerLocator.php
@@ -11,22 +11,18 @@
  * Whenever a Lambda function executes, this class will `require` that PHP file
  * and return what the file returns.
  *
- * @internal
  * @see \Bref\Bref::setContainer()
  */
 class FileHandlerLocator implements ContainerInterface
 {
-    /** @var string */
-    private $directory;
+    private string $directory;
 
     public function __construct(?string $directory = null)
     {
-        $this->directory = $directory ?: ($_SERVER['LAMBDA_TASK_ROOT'] ?? null);
+        $directory = $directory ?: ($_SERVER['LAMBDA_TASK_ROOT'] ?? null);
 
-        // When running locally (`bref local` CLI command) `LAMBDA_TASK_ROOT` is undefined
-        if (! $this->directory) {
-            $this->directory = getcwd();
-        }
+        // When running locally (`serverless bref:local` CLI command) `LAMBDA_TASK_ROOT` is undefined
+        $this->directory = $directory ?: getcwd();
     }
 
     /**
@@ -39,7 +35,6 @@ public function get($id)
             throw new HandlerNotFound("Handler `$handlerFile` doesn't exist");
         }
 
-        /** @noinspection PhpIncludeInspection */
         $handler = require $handlerFile;
 
         if (! (is_object($handler) || is_array($handler))) {
diff --git a/src/Runtime/Invoker.php b/src/Runtime/Invoker.php
index 05c093c82..925ab27bd 100644
--- a/src/Runtime/Invoker.php
+++ b/src/Runtime/Invoker.php
@@ -13,12 +13,7 @@
  */
 final class Invoker
 {
-    /**
-     * @param mixed $handler
-     * @param mixed $event
-     * @return mixed
-     */
-    public function invoke($handler, $event, Context $context)
+    public function invoke(mixed $handler, mixed $event, Context $context): mixed
     {
         // PSR-15 adapter
         if ($handler instanceof RequestHandlerInterface) {
diff --git a/src/Runtime/LambdaRuntime.php b/src/Runtime/LambdaRuntime.php
index d73990caa..6b5a0816a 100755
--- a/src/Runtime/LambdaRuntime.php
+++ b/src/Runtime/LambdaRuntime.php
@@ -2,11 +2,14 @@
 
 namespace Bref\Runtime;
 
+use Bref\Bref;
 use Bref\Context\Context;
 use Bref\Context\ContextBuilder;
 use Bref\Event\Handler;
+use CurlHandle;
 use Exception;
 use Psr\Http\Server\RequestHandlerInterface;
+use Throwable;
 
 /**
  * Client for the AWS Lambda runtime API.
@@ -30,20 +33,13 @@
  */
 final class LambdaRuntime
 {
-    /** @var resource|\CurlHandle|null */
+    /** @var resource|CurlHandle|null */
     private $curlHandleNext;
-
-    /** @var resource|\CurlHandle|null */
+    /** @var resource|CurlHandle|null */
     private $curlHandleResult;
-
-    /** @var string */
-    private $apiUrl;
-
-    /** @var Invoker */
-    private $invoker;
-
-    /** @var string */
-    private $layer;
+    private string $apiUrl;
+    private Invoker $invoker;
+    private string $layer;
 
     public static function fromEnvironmentVariable(string $layer): self
     {
@@ -80,10 +76,11 @@ public function __destruct()
      * @return bool true if event was successfully handled
      * @throws Exception
      */
-    public function processNextEvent($handler): bool
+    public function processNextEvent(Handler | RequestHandlerInterface | callable $handler): bool
     {
         [$event, $context] = $this->waitNextInvocation();
-        \assert($context instanceof Context);
+
+        Bref::triggerHooks('beforeInvoke');
 
         $this->ping();
 
@@ -91,7 +88,7 @@ public function processNextEvent($handler): bool
             $result = $this->invoker->invoke($handler, $event, $context);
 
             $this->sendResponse($context->getAwsRequestId(), $result);
-        } catch (\Throwable $e) {
+        } catch (Throwable $e) {
             $this->signalFailure($context->getAwsRequestId(), $e);
 
             return false;
@@ -105,17 +102,19 @@ public function processNextEvent($handler): bool
      *
      * This call is blocking because the Lambda runtime API is blocking.
      *
+     * @return array{0: mixed, 1: Context}
+     *
      * @see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-next
      */
     private function waitNextInvocation(): array
     {
         if ($this->curlHandleNext === null) {
-            $this->curlHandleNext = curl_init("http://{$this->apiUrl}/2018-06-01/runtime/invocation/next");
+            $this->curlHandleNext = curl_init("http://$this->apiUrl/2018-06-01/runtime/invocation/next");
             curl_setopt($this->curlHandleNext, CURLOPT_FOLLOWLOCATION, true);
             curl_setopt($this->curlHandleNext, CURLOPT_FAILONERROR, true);
             // Set a custom user agent so that AWS can estimate Bref usage in custom runtimes
             $phpVersion = substr(PHP_VERSION, 0, strpos(PHP_VERSION, '.', 2));
-            curl_setopt($this->curlHandleNext, CURLOPT_USERAGENT, "bref/{$this->layer}/$phpVersion");
+            curl_setopt($this->curlHandleNext, CURLOPT_USERAGENT, "bref/$this->layer/$phpVersion");
         }
 
         // Retrieve invocation ID
@@ -167,26 +166,24 @@ private function waitNextInvocation(): array
             throw new Exception('Failed to determine the Lambda invocation ID');
         }
 
-        $event = json_decode($body, true);
+        $event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
 
         return [$event, $context];
     }
 
     /**
-     * @param mixed $responseData
-     *
      * @see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-response
      */
-    private function sendResponse(string $invocationId, $responseData): void
+    private function sendResponse(string $invocationId, mixed $responseData): void
     {
-        $url = "http://{$this->apiUrl}/2018-06-01/runtime/invocation/$invocationId/response";
+        $url = "http://$this->apiUrl/2018-06-01/runtime/invocation/$invocationId/response";
         $this->postJson($url, $responseData);
     }
 
     /**
      * @see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-invokeerror
      */
-    private function signalFailure(string $invocationId, \Throwable $error): void
+    private function signalFailure(string $invocationId, Throwable $error): void
     {
         $stackTraceAsArray = explode(PHP_EOL, $error->getTraceAsString());
         $errorFormatted = [
@@ -213,10 +210,11 @@ private function signalFailure(string $invocationId, \Throwable $error): void
         // Log the exception in CloudWatch
         // We aim to use the same log format as what we can see when throwing an exception in the NodeJS runtime
         // See https://github.com/brefphp/bref/pull/579
+        /** @noinspection JsonEncodingApiUsageInspection */
         echo $invocationId . "\tInvoke Error\t" . json_encode($errorFormatted) . PHP_EOL;
 
         // Send an "error" Lambda response
-        $url = "http://{$this->apiUrl}/2018-06-01/runtime/invocation/$invocationId/error";
+        $url = "http://$this->apiUrl/2018-06-01/runtime/invocation/$invocationId/error";
         $this->postJson($url, [
             'errorType' => get_class($error),
             'errorMessage' => $error->getMessage(),
@@ -228,8 +226,10 @@ private function signalFailure(string $invocationId, \Throwable $error): void
      * Abort the lambda and signal to the runtime API that we failed to initialize this instance.
      *
      * @see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-initerror
+     *
+     * @phpstan-return never-returns
      */
-    public function failInitialization(string $message, ?\Throwable $error = null): void
+    public function failInitialization(string $message, ?Throwable $error = null): void
     {
         // Log the exception in CloudWatch
         echo "$message\n";
@@ -248,7 +248,7 @@ public function failInitialization(string $message, ?\Throwable $error = null):
             );
         }
 
-        $url = "http://{$this->apiUrl}/2018-06-01/runtime/init/error";
+        $url = "http://$this->apiUrl/2018-06-01/runtime/init/error";
         $this->postJson($url, [
             'errorMessage' => $message . ' ' . ($error ? $error->getMessage() : ''),
             'errorType' => $error ? get_class($error) : 'Internal',
@@ -258,11 +258,9 @@ public function failInitialization(string $message, ?\Throwable $error = null):
         exit(1);
     }
 
-    /**
-     * @param mixed $data
-     */
-    private function postJson(string $url, $data): void
+    private function postJson(string $url, mixed $data): void
     {
+        /** @noinspection JsonEncodingApiUsageInspection */
         $jsonData = json_encode($data);
         if ($jsonData === false) {
             throw new Exception(sprintf(
@@ -326,14 +324,14 @@ private function closeCurlHandleResult(): void
      * HOW?
      * The data is sent via the statsd protocol, over UDP.
      * Unlike TCP, UDP does not check that the message correctly arrived to the server.
-     * It doesn't even establishes a connection. That means that UDP is extremely fast:
+     * It doesn't even establish a connection. That means that UDP is extremely fast:
      * the data is sent over the network and the code moves on to the next line.
      * When actually sending data, the overhead of that ping takes about 150 micro-seconds.
      * However, this function actually sends data every 100 invocation, because we don't
      * need to measure *all* invocations. We only need an approximation.
      * That means that 99% of the time, no data is sent, and the function takes 30 micro-seconds.
      * If we average all executions, the overhead of that ping is about 31 micro-seconds.
-     * Given that it is much much less than even 1 milli-second, we consider that overhead
+     * Given that it is much much less than even 1 millisecond, we consider that overhead
      * negligible.
      *
      * CAN I DISABLE IT?
@@ -355,6 +353,7 @@ private function ping(): void
 
         // Only run the code in 1% of requests
         // We don't need to collect all invocations, only to get an approximation
+        /** @noinspection RandomApiMigrationInspection */
         if (rand(0, 99) > 0) {
             return;
         }
diff --git a/src/Websocket/SimpleWebsocketClient.php b/src/Websocket/SimpleWebsocketClient.php
deleted file mode 100644
index 8136ff31e..000000000
--- a/src/Websocket/SimpleWebsocketClient.php
+++ /dev/null
@@ -1,70 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Websocket;
-
-use AsyncAws\Core\Credentials\CredentialProvider;
-use Symfony\Component\HttpClient\HttpClient;
-use Symfony\Contracts\HttpClient\HttpClientInterface;
-
-/**
- * A simple alternative for communicating with websocket connections.
- */
-final class SimpleWebsocketClient
-{
-    /** @var string */
-    private $stage;
-
-    /** @var WebsocketClient */
-    private $client;
-
-    public static function create(string $apiId, string $region, string $stage, int $timeout = 10): SimpleWebsocketClient
-    {
-        return new static(
-            $apiId,
-            $region,
-            $stage,
-            HttpClient::create([
-                'timeout' => $timeout,
-            ])
-        );
-    }
-
-    /**
-     * @internal Method only public for tests, use `create()` instead.
-     */
-    public function __construct(
-        string $apiId,
-        string $region,
-        string $stage,
-        HttpClientInterface $httpClient,
-        ?CredentialProvider $credentialProvider = null
-    ) {
-        $this->stage = $stage;
-        $this->client = new WebsocketClient(
-            [
-                'region' => $region,
-                'endpoint' => sprintf('https://%s.execute-api.%s.amazonaws.com', $apiId, $region),
-            ],
-            $credentialProvider,
-            $httpClient
-        );
-    }
-
-    public function disconnect(string $connectionId): void
-    {
-        $this->client->request('DELETE', sprintf('/%s/@connections/%s', $this->stage, $connectionId));
-    }
-
-    public function message(string $connectionId, string $body): void
-    {
-        $this->client->request('POST', sprintf('/%s/@connections/%s', $this->stage, $connectionId), $body);
-    }
-
-    public function status(string $connectionId): WebsocketClientStatus
-    {
-        return new WebsocketClientStatus(
-            $this->client->request('GET', sprintf('/%s/@connections/%s', $this->stage, $connectionId))
-                ->toArray()
-        );
-    }
-}
diff --git a/src/Websocket/WebsocketClient.php b/src/Websocket/WebsocketClient.php
deleted file mode 100644
index b22ba76d9..000000000
--- a/src/Websocket/WebsocketClient.php
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Websocket;
-
-use AsyncAws\Core\AbstractApi;
-use AsyncAws\Core\Request;
-use AsyncAws\Core\Response;
-use AsyncAws\Core\Stream\StreamFactory;
-
-/**
- * This class sole purpose it to act as a proxy for communicating with the ApiGateway API.
- * Because async-aws does not provide a native websocket client we've had to create our own
- * client using the same base class as all other async-aws clients.
- *
- * In time if async-aws provide a websocket client this will be replaced entirely.
- *
- * Exceptions thrown should in theory be the same as they're all coming from async-aws.
- *
- * @internal
- */
-final class WebsocketClient extends AbstractApi
-{
-    public function request(string $method, string $url, ?string $body = null): Response
-    {
-        return $this->getResponse(new Request($method, $url, [], [], StreamFactory::create($body)));
-    }
-
-    protected function getEndpointMetadata(?string $region): array
-    {
-        return [
-            'endpoint' => $this->getConfiguration()->get('endpoint'),
-            'signRegion' => $region ?? $this->getConfiguration()->get('region'),
-            'signService' => 'execute-api',
-            'signVersions' => ['v4'],
-        ];
-    }
-}
diff --git a/src/Websocket/WebsocketClientStatus.php b/src/Websocket/WebsocketClientStatus.php
deleted file mode 100644
index 220b9167d..000000000
--- a/src/Websocket/WebsocketClientStatus.php
+++ /dev/null
@@ -1,58 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Websocket;
-
-use DateTimeImmutable;
-
-class WebsocketClientStatus
-{
-    /** @var string */
-    private $sourceIp;
-
-    /** @var string */
-    private $userAgent;
-
-    /** @var DateTimeImmutable */
-    private $connectedAt;
-
-    /** @var DateTimeImmutable */
-    private $lastActiveAt;
-
-    public function __construct(array $input)
-    {
-        $this->sourceIp = $input['identity']['sourceIp'];
-        $this->userAgent = $input['identity']['userAgent'];
-        $this->connectedAt = new DateTimeImmutable($input['connectedAt']);
-        $this->lastActiveAt = new DateTimeImmutable($input['lastActiveAt']);
-    }
-
-    public function getSourceIp(): string
-    {
-        return $this->sourceIp;
-    }
-
-    public function getUserAgent(): string
-    {
-        return $this->userAgent;
-    }
-
-    public function getConnectedAt(): DateTimeImmutable
-    {
-        return $this->connectedAt;
-    }
-
-    public function getLastActiveAt(): DateTimeImmutable
-    {
-        return $this->lastActiveAt;
-    }
-
-    public function toArray(): array
-    {
-        return [
-            'sourceIp' => $this->sourceIp,
-            'userAgent' => $this->userAgent,
-            'connectedAt' => $this->connectedAt,
-            'lastActiveAt' => $this->lastActiveAt,
-        ];
-    }
-}
diff --git a/src/bref-local b/src/bref-local
new file mode 100755
index 000000000..d370bc924
--- /dev/null
+++ b/src/bref-local
@@ -0,0 +1,65 @@
+#!/usr/bin/env php
+<?php declare(strict_types=1);
+
+use Bref\Bref;
+use Bref\Context\Context;
+use Bref\Runtime\Invoker;
+use Psr\Container\NotFoundExceptionInterface;
+
+if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
+    require_once __DIR__ . '/../vendor/autoload.php';
+} elseif (file_exists(__DIR__ . '/../../autoload.php')) {
+    /** @noinspection PhpIncludeInspection */
+    require_once __DIR__ . '/../../autoload.php';
+} else {
+    /** @noinspection PhpIncludeInspection */
+    require_once __DIR__ . '/../../../autoload.php';
+}
+
+$handler = $argv[1] ?? '';
+$data = $argv[2] ?? null;
+
+try {
+    $handler = Bref::getContainer()->get($handler);
+} catch (NotFoundExceptionInterface $e) {
+    throw new Exception($e->getMessage() . PHP_EOL . 'Reminder: `bref-local` can invoke event-driven functions that use the FUNCTION runtime, not the web app (or "FPM") runtime. Check out https://bref.sh/docs/web-apps/local-development.html to run web applications locally.');
+}
+
+try {
+    $event = $data ? json_decode($data, true, 512, JSON_THROW_ON_ERROR) : null;
+} catch (JsonException $e) {
+    throw new Exception('The JSON provided for the event data is invalid JSON.');
+}
+
+// Same configuration as the Bref runtime on Lambda
+ini_set('display_errors', '1');
+error_reporting(E_ALL);
+
+$startTime = logStart();
+
+try {
+    $invoker = new Invoker;
+    $result = $invoker->invoke($handler, $event, Context::fake());
+} catch (Throwable $e) {
+    echo get_class($e) . ': ' . $e->getMessage() . PHP_EOL;
+    echo 'Stack trace:' . PHP_EOL;
+    echo $e->getTraceAsString() . PHP_EOL;
+    exit(1);
+}
+
+logEnd($startTime);
+// Show the invocation result
+echo json_encode($result, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . PHP_EOL;
+
+function logStart(): float
+{
+    echo "START\n";
+    return microtime(true);
+}
+
+function logEnd(float $startTime): void
+{
+    $duration = ceil((microtime(true) - $startTime) * 1000);
+    $memoryUsed = ceil(memory_get_usage() / 1024 / 1024);
+    echo "END Duration: $duration ms Max Memory Used: $memoryUsed MB\n\n";
+}
diff --git a/template/function/serverless.yml b/template/function/serverless.yml
index ebecdf803..d8bd7d52e 100644
--- a/template/function/serverless.yml
+++ b/template/function/serverless.yml
@@ -3,8 +3,6 @@ service: app
 provider:
     name: aws
     region: us-east-1
-    runtime: provided.al2
-    deploymentMethod: direct # fastest deployment method
 
 plugins:
     - ./vendor/bref/bref
@@ -13,8 +11,7 @@ functions:
     hello:
         handler: index.php
         description: ''
-        layers:
-            - ${bref:layer.php-PHP_VERSION}
+        runtime: php-PHP_VERSION
 
 # Exclude files from deployment
 package:
diff --git a/template/http/serverless.yml b/template/http/serverless.yml
index 8e2831de8..c5f68ae92 100644
--- a/template/http/serverless.yml
+++ b/template/http/serverless.yml
@@ -3,8 +3,6 @@ service: app
 provider:
     name: aws
     region: us-east-1
-    runtime: provided.al2
-    deploymentMethod: direct # fastest deployment method
 
 plugins:
     - ./vendor/bref/bref
@@ -13,9 +11,8 @@ functions:
     api:
         handler: index.php
         description: ''
+        runtime: php-PHP_VERSION-fpm
         timeout: 28 # in seconds (API Gateway has a timeout of 29 seconds)
-        layers:
-            - ${bref:layer.php-PHP_VERSION-fpm}
         events:
             -   httpApi: '*'
 
diff --git a/runtime/layers/regions.json b/utils/layers.json/regions.json
similarity index 100%
rename from runtime/layers/regions.json
rename to utils/layers.json/regions.json
diff --git a/runtime/layers/layer-list.php b/utils/layers.json/update.php
similarity index 82%
rename from runtime/layers/layer-list.php
rename to utils/layers.json/update.php
index c60603ac6..c20f67dd1 100644
--- a/runtime/layers/layer-list.php
+++ b/utils/layers.json/update.php
@@ -6,9 +6,9 @@
  * `layers.json` contains the layer versions that Bref should use.
  */
 
-use Aws\Sts\StsClient; # AsyncAWS doesn't support regional endpoints: https://github.com/async-aws/aws/issues/1061
 use AsyncAws\Lambda\LambdaClient;
 use AsyncAws\Lambda\ValueObject\LayerVersionsListItem;
+use Aws\Sts\StsClient; // AsyncAWS doesn't support regional endpoints: https://github.com/async-aws/aws/issues/1061
 
 require_once __DIR__ . '/../../vendor/autoload.php';
 
@@ -19,10 +19,12 @@
     'php-81-fpm',
     'php-80',
     'php-80-fpm',
-    'php-74',
-    'php-74-fpm',
-    'php-73',
-    'php-73-fpm',
+    'arm-php-82',
+    'arm-php-82-fpm',
+    'arm-php-81',
+    'arm-php-81-fpm',
+    'arm-php-80',
+    'arm-php-80-fpm',
     'console',
 ];
 
@@ -35,7 +37,7 @@
     $layers = listLayers($client, $region);
 
     foreach (LAYER_NAMES as $layerName) {
-        $export[$layerName][$region] = $layers[$layerName];
+        $export[$layerName][$region] = $layers[$layerName] ?? '';
     }
 
     echo "$region\n";
@@ -52,11 +54,11 @@ function lambdaClient(string $region): LambdaClient
         $stsClient = new StsClient([
             'sts_regional_endpoints' => 'regional',
             'region' => $region,
-            'version' => '2011-06-15'
+            'version' => '2011-06-15',
         ]);
 
         $credentials = $stsClient->AssumeRole([
-            'RoleArn' => 'arn:aws:iam::209497400698:role/bref-layer-publisher',
+            'RoleArn' => 'arn:aws:iam::534081306603:role/bref-layer-publisher',
             'RoleSessionName' => 'bref-layer-builder',
         ]);
 
@@ -80,7 +82,7 @@ function listLayers(LambdaClient $lambda, string $selectedRegion): array
 
     foreach (LAYER_NAMES as $layerName) {
         $results[$layerName] = $lambda->listLayerVersions([
-            'LayerName' => sprintf('arn:aws:lambda:%s:209497400698:layer:%s', $selectedRegion, $layerName),
+            'LayerName' => sprintf('arn:aws:lambda:%s:534081306603:layer:%s', $selectedRegion, $layerName),
             'MaxItems' => 1,
         ]);
     }


Test patch:
diff --git a/runtime/helpers/extensions-test.sh b/runtime/helpers/extensions-test.sh
deleted file mode 100755
index c44d02d0f..000000000
--- a/runtime/helpers/extensions-test.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/usr/bin/env bash
-
-if php -m 2>&1 >/dev/null | grep -q 'Unable'; then
-   php -m 2>&1 >/dev/null | grep 'Unable to load dynamic library'
-   exit 1
-fi
diff --git a/runtime/layers/tests.php b/runtime/layers/tests.php
deleted file mode 100644
index 772f21c4a..000000000
--- a/runtime/layers/tests.php
+++ /dev/null
@@ -1,106 +0,0 @@
-<?php /** @noinspection ALL */
-declare(strict_types=1);
-
-/**
- * This file runs tests on Docker images.
- */
-
-// All layers
-$allLayers = [
-    'bref/php-73',
-    'bref/php-74',
-    'bref/php-80',
-    'bref/php-81',
-    'bref/php-82',
-    'bref/php-73-fpm',
-    'bref/php-74-fpm',
-    'bref/php-80-fpm',
-    'bref/php-81-fpm',
-    'bref/php-82-fpm',
-    'bref/php-73-fpm-dev',
-    'bref/php-74-fpm-dev',
-    'bref/php-80-fpm-dev',
-    'bref/php-81-fpm-dev',
-    // 'bref/php-82-fpm-dev',
-];
-foreach ($allLayers as $layer) {
-    // Working directory
-    $workdir = trim(`docker run --rm --entrypoint pwd $layer`);
-    assertEquals('/var/task', $workdir);
-    echo '.';
-
-    // PHP runs correctly
-    $phpVersion = trim(`docker run --rm --entrypoint php $layer -v`);
-    assertMatchesRegex('/PHP (7|8)\.\d+\.\d+/', $phpVersion);
-    echo '.';
-
-    // Test extensions load correctly
-    // Skip this for PHP 8.0, 8.1, and 8.2 until all extensions are supported
-    if (strpos($layer, 'php-8') === false) {
-        exec("docker run --rm -v \${PWD}/helpers:/var/task/ --entrypoint /var/task/extensions-test.sh $layer", $output, $exitCode);
-        if ($exitCode !== 0) {
-            throw new Exception(implode(PHP_EOL, $output), $exitCode);
-        }
-        echo '.';
-    }
-}
-
-// FPM layers
-$fpmLayers = [
-    'bref/php-73-fpm',
-    'bref/php-74-fpm',
-    'bref/php-80-fpm',
-    'bref/php-81-fpm',
-    'bref/php-82-fpm', 
-    'bref/php-73-fpm-dev',
-    'bref/php-74-fpm-dev',
-    'bref/php-80-fpm-dev',
-    'bref/php-81-fpm-dev',
-    // 'bref/php-82-fpm-dev', 
-];
-foreach ($fpmLayers as $layer) {
-    // PHP-FPM is installed
-    $phpVersion = trim(`docker run --rm --entrypoint php-fpm $layer -v`);
-    assertMatchesRegex('/PHP (7|8)\.\d+\.\d+/', $phpVersion);
-    echo '.';
-}
-
-// dev layers
-$devLayers = [
-    'bref/php-73-fpm-dev',
-    'bref/php-74-fpm-dev',
-    'bref/php-80-fpm-dev',
-    'bref/php-81-fpm-dev',
-    // 'bref/php-82-fpm-dev',
-];
-$devExtensions = [
-    'xdebug',
-    'blackfire',
-];
-foreach ($devLayers as $layer) {
-    exec("docker run --rm -v \${PWD}/helpers:/var/task/ --entrypoint php $layer -m", $output, $exitCode);
-    $notLoaded = array_diff($devExtensions, $output);
-    // all development extensions are loaded
-    if ($exitCode !== 0 || count($notLoaded) > 0) {
-        throw new Exception(implode(PHP_EOL, array_map(function ($extension) {
-            return "Extension $extension is not loaded";
-        }, $notLoaded)), $exitCode);
-    }
-    echo '.';
-}
-
-echo "\nTests passed\n";
-
-function assertEquals($expected, $actual)
-{
-    if ($expected !== $actual) {
-        throw new Exception("$actual is not equal to expected $expected");
-    }
-}
-
-function assertMatchesRegex(string $expected, string $actual)
-{
-    if (preg_match($expected, $actual) === false) {
-        throw new Exception("$actual does not match regex $expected");
-    }
-}
diff --git a/tests/BrefTest.php b/tests/BrefTest.php
index 634805c5c..4ebbd05cd 100644
--- a/tests/BrefTest.php
+++ b/tests/BrefTest.php
@@ -10,8 +10,6 @@ class BrefTest extends TestCase
 {
     protected function setUp(): void
     {
-        parent::setUp();
-
         Bref::reset();
     }
 
@@ -32,4 +30,34 @@ public function test override the container(): void
 
         $this->assertSame($container, Bref::getContainer());
     }
+
+    public function test hooks(): void
+    {
+        $beforeStartup1 = false;
+        $beforeStartup2 = false;
+        $beforeInvoke = false;
+
+        // Check that we can set multiple handlers
+        Bref::beforeStartup(function () use (&$beforeStartup1) {
+            return $beforeStartup1 = true;
+        });
+        Bref::beforeStartup(function () use (&$beforeStartup2) {
+            return $beforeStartup2 = true;
+        });
+        Bref::beforeInvoke(function () use (&$beforeInvoke) {
+            return $beforeInvoke = true;
+        });
+
+        $this->assertFalse($beforeStartup1);
+        $this->assertFalse($beforeStartup2);
+        $this->assertFalse($beforeInvoke);
+
+        Bref::triggerHooks('beforeStartup');
+        $this->assertTrue($beforeStartup1);
+        $this->assertTrue($beforeStartup2);
+        $this->assertFalse($beforeInvoke);
+
+        Bref::triggerHooks('beforeInvoke');
+        $this->assertTrue($beforeInvoke);
+    }
 }
diff --git a/tests/ConsoleRuntime/MainTest.php b/tests/ConsoleRuntime/MainTest.php
new file mode 100644
index 000000000..069ba9ee1
--- /dev/null
+++ b/tests/ConsoleRuntime/MainTest.php
@@ -0,0 +1,21 @@
+<?php declare(strict_types=1);
+
+namespace Bref\Test\ConsoleRuntime;
+
+use Bref\Bref;
+use Bref\ConsoleRuntime\Main;
+use Exception;
+use PHPUnit\Framework\TestCase;
+
+class MainTest extends TestCase
+{
+    public function test startup hook is called()
+    {
+        Bref::beforeStartup(function () {
+            throw new Exception('This should be called');
+        });
+
+        $this->expectExceptionMessage('This should be called');
+        Main::run();
+    }
+}
diff --git a/tests/Event/Http/HttpRequestEventTest.php b/tests/Event/Http/HttpRequestEventTest.php
index 43bad8877..eef34bf05 100644
--- a/tests/Event/Http/HttpRequestEventTest.php
+++ b/tests/Event/Http/HttpRequestEventTest.php
@@ -7,12 +7,11 @@
 
 class HttpRequestEventTest extends CommonHttpTest
 {
-    /** @var HttpRequestEvent */
-    private $event;
+    private HttpRequestEvent $event;
 
     protected function fromFixture(string $file): void
     {
-        $this->event = new HttpRequestEvent(json_decode(file_get_contents($file), true));
+        $this->event = new HttpRequestEvent(json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR));
     }
 
     protected function assertBody(string $expected): void
@@ -133,7 +132,7 @@ protected function assertPathParameters(array $expected): void
 
     protected function assertBasicAuthUser(string $expected): void
     {
-        [$user, $pass] = $this->event->getBasicAuthCredentials();
+        [$user] = $this->event->getBasicAuthCredentials();
         $this->assertEquals($expected, $user);
     }
 
diff --git a/tests/Event/Http/Psr7BridgeTest.php b/tests/Event/Http/Psr7BridgeTest.php
index 855f568dc..869a7e347 100644
--- a/tests/Event/Http/Psr7BridgeTest.php
+++ b/tests/Event/Http/Psr7BridgeTest.php
@@ -9,16 +9,17 @@
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Message\UploadedFileInterface;
 
+use function assert;
+
 class Psr7BridgeTest extends CommonHttpTest
 {
-    /** @var ServerRequestInterface */
-    private $request;
+    private ServerRequestInterface $request;
 
     public function test I can create a response from a PSR7 response()
     {
         $psr7Response = new Response(404, [
             'Content-Type' => 'application/json',
-        ], json_encode(['foo' => 'bar']));
+        ], json_encode(['foo' => 'bar'], JSON_THROW_ON_ERROR));
 
         $response = Psr7Bridge::convertResponse($psr7Response);
         self::assertSame([
@@ -27,15 +28,14 @@ public function test I can create a response from a PSR7 response()
             'headers' => [
                 'Content-Type' => 'application/json',
             ],
-            'body' => json_encode(['foo' => 'bar']),
+            'body' => json_encode(['foo' => 'bar'], JSON_THROW_ON_ERROR),
         ], $response->toApiGatewayFormat());
     }
 
     protected function fromFixture(string $file): void
     {
-        $event = new HttpRequestEvent(json_decode(file_get_contents($file), true));
-        $context = new Context('', 0, '', '');
-        $this->request = Psr7Bridge::convertRequest($event, $context);
+        $event = new HttpRequestEvent(json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR));
+        $this->request = Psr7Bridge::convertRequest($event, Context::fake());
     }
 
     protected function assertBody(string $expected): void
@@ -138,7 +138,7 @@ protected function assertUploadedFile(
     ): void {
         $uploadedFiles = $this->request->getUploadedFiles();
         $uploadedFile = $uploadedFiles[$key];
-        \assert($uploadedFile instanceof UploadedFileInterface);
+        assert($uploadedFile instanceof UploadedFileInterface);
         $this->assertEquals($filename, $uploadedFile->getClientFilename());
         $this->assertEquals($mimeType, $uploadedFile->getClientMediaType());
         $this->assertEquals($error, $uploadedFile->getError());
diff --git a/tests/Event/Sqs/SqsEventTest.php b/tests/Event/Sqs/SqsEventTest.php
index a0fad0e18..0f678c959 100644
--- a/tests/Event/Sqs/SqsEventTest.php
+++ b/tests/Event/Sqs/SqsEventTest.php
@@ -2,7 +2,7 @@
 
 namespace Bref\Test\Event\Sqs;
 
-use Bref\Context\ContextBuilder;
+use Bref\Context\Context;
 use Bref\Event\InvalidLambdaEvent;
 use Bref\Event\Sqs\SqsEvent;
 use Bref\Test\Fixture\SqsFakeHandler;
@@ -47,7 +47,7 @@ public function test invalid event()
     public function test partial failure()
     {
         $event = json_decode(file_get_contents(__DIR__ . '/handler.json'), true);
-        $result = (new SqsFakeHandler)->handle($event, (new ContextBuilder)->buildContext());
+        $result = (new SqsFakeHandler)->handle($event, Context::fake());
 
         self::assertSame($result, [
             'batchItemFailures' => [
diff --git a/tests/Handler/FpmHandlerLoadBalancerTest.php b/tests/FpmRuntime/FpmHandlerLoadBalancerTest.php
similarity index 86%
rename from tests/Handler/FpmHandlerLoadBalancerTest.php
rename to tests/FpmRuntime/FpmHandlerLoadBalancerTest.php
index ad420361e..a41701783 100644
--- a/tests/Handler/FpmHandlerLoadBalancerTest.php
+++ b/tests/FpmRuntime/FpmHandlerLoadBalancerTest.php
@@ -1,17 +1,15 @@
 <?php declare(strict_types=1);
 
-namespace Bref\Test\Handler;
+namespace Bref\Test\FpmRuntime;
 
 use Bref\Context\Context;
-use Bref\Event\Http\FpmHandler;
+use Bref\FpmRuntime\FpmHandler;
 use PHPUnit\Framework\TestCase;
 
 final class FpmHandlerLoadBalancerTest extends TestCase
 {
-    /** @var FpmHandler|null */
-    private $fpm;
-    /** @var Context */
-    private $fakeContext;
+    private ?FpmHandler $fpm = null;
+    private Context $fakeContext;
 
     public function setUp(): void
     {
@@ -35,7 +33,7 @@ public function tearDown(): void
      */
     public function test alb query strings()
     {
-        $loadBalancerEvent = file_get_contents(__DIR__ . '/PhpFpm/alb-query-string.json');
+        $loadBalancerEvent = file_get_contents(__DIR__ . '/fixtures/alb-query-string.json');
 
         $response = $this->get('request.php', json_decode($loadBalancerEvent, true));
 
@@ -55,7 +53,7 @@ public function test alb query strings()
      */
     public function test alb query strings encoded()
     {
-        $loadBalancerEvent = file_get_contents(__DIR__ . '/PhpFpm/alb-query-string-encoded.json');
+        $loadBalancerEvent = file_get_contents(__DIR__ . '/fixtures/alb-query-string-encoded.json');
 
         $response = $this->get('request.php', json_decode($loadBalancerEvent, true));
 
@@ -69,7 +67,7 @@ public function test alb query strings encoded()
 
     public function test alb query strings mixed encoding()
     {
-        $loadBalancerEvent = file_get_contents(__DIR__ . '/PhpFpm/alb-query-string-mixed-encoding.json');
+        $loadBalancerEvent = file_get_contents(__DIR__ . '/fixtures/alb-query-string-mixed-encoding.json');
 
         $response = $this->get('request.php', json_decode($loadBalancerEvent, true));
 
@@ -81,7 +79,7 @@ public function test alb query strings mixed encoding()
 
     public function test alb query strings with multi value enabled()
     {
-        $loadBalancerEvent = file_get_contents(__DIR__ . '/PhpFpm/alb-multi-value-query-string.json');
+        $loadBalancerEvent = file_get_contents(__DIR__ . '/fixtures/alb-multi-value-query-string.json');
 
         $response = $this->get('request.php', json_decode($loadBalancerEvent, true));
 
@@ -99,7 +97,7 @@ public function test alb query strings with multi value enabled()
 
     public function test alb query strings encoded with multi value enabled()
     {
-        $loadBalancerEvent = file_get_contents(__DIR__ . '/PhpFpm/alb-multi-value-query-string-encoded.json');
+        $loadBalancerEvent = file_get_contents(__DIR__ . '/fixtures/alb-multi-value-query-string-encoded.json');
 
         $response = $this->get('request.php', json_decode($loadBalancerEvent, true));
 
@@ -113,7 +111,7 @@ public function test alb query strings encoded with multi value enabled(
 
     public function test alb query strings with multi value enabled includes numeric keys()
     {
-        $loadBalancerEvent = file_get_contents(__DIR__ . '/PhpFpm/alb-multi-value-query-string-with-numeric-keys.json');
+        $loadBalancerEvent = file_get_contents(__DIR__ . '/fixtures/alb-multi-value-query-string-with-numeric-keys.json');
 
         $response = $this->get('request.php', json_decode($loadBalancerEvent, true));
 
@@ -138,7 +136,7 @@ public function test alb query strings with multi value enabled includes
 
     private function get(string $file, ?array $event = null): array
     {
-        $this->startFpm(__DIR__ . '/PhpFpm/' . $file);
+        $this->startFpm(__DIR__ . '/fixtures/' . $file);
 
         return $this->fpm->handle($event ?? [
             'version' => '1.0',
@@ -151,7 +149,7 @@ private function startFpm(string $handler): void
         if ($this->fpm) {
             $this->fpm->stop();
         }
-        $this->fpm = new FpmHandler($handler, __DIR__ . '/PhpFpm/php-fpm.conf');
+        $this->fpm = new FpmHandler($handler, __DIR__ . '/fixtures/php-fpm.conf');
         $this->fpm->start();
     }
 }
diff --git a/tests/Handler/FpmHandlerTest.php b/tests/FpmRuntime/FpmHandlerTest.php
similarity index 95%
rename from tests/Handler/FpmHandlerTest.php
rename to tests/FpmRuntime/FpmHandlerTest.php
index 8bd7d20f2..19f903980 100644
--- a/tests/Handler/FpmHandlerTest.php
+++ b/tests/FpmRuntime/FpmHandlerTest.php
@@ -1,10 +1,10 @@
 <?php declare(strict_types=1);
 
-namespace Bref\Test\Handler;
+namespace Bref\Test\FpmRuntime;
 
 use Bref\Context\Context;
-use Bref\Event\Http\FastCgi\Timeout;
-use Bref\Event\Http\FpmHandler;
+use Bref\FpmRuntime\FastCgi\Timeout;
+use Bref\FpmRuntime\FpmHandler;
 use Bref\Test\HttpRequestProxyTest;
 use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
 use PHPUnit\Framework\TestCase;
@@ -13,10 +13,8 @@ class FpmHandlerTest extends TestCase implements HttpRequestProxyTest
 {
     use ArraySubsetAsserts;
 
-    /** @var FpmHandler|null */
-    private $fpm;
-    /** @var Context */
-    private $fakeContext;
+    private ?FpmHandler $fpm = null;
+    private Context $fakeContext;
 
     public function setUp(): void
     {
@@ -1110,12 +1108,10 @@ public function test response with cookies()
     {
         $cookieHeader = $this->get('cookies.php')['headers']['Set-Cookie'];
 
-        // If PHP 8.2 or greater, the formatting is slightly different for some reason
-        if (PHP_VERSION_ID >= 80200) {
-            self::assertEquals('MyCookie=MyValue; expires=Fri, 12 Jan 2018 08:32:03 GMT; Max-Age=0; path=/hello/; domain=example.com; secure; HttpOnly', $cookieHeader);
-        } else {
-            self::assertEquals('MyCookie=MyValue; expires=Fri, 12-Jan-2018 08:32:03 GMT; Max-Age=0; path=/hello/; domain=example.com; secure; HttpOnly', $cookieHeader);
-        }
+        // Dependending on the PHP version the date formatting is slightly different
+        $cookieHeader = str_replace('12 Jan 2018', '12-Jan-2018', $cookieHeader);
+
+        self::assertEquals('MyCookie=MyValue; expires=Fri, 12-Jan-2018 08:32:03 GMT; Max-Age=0; path=/hello/; domain=example.com; secure; HttpOnly', $cookieHeader);
     }
 
     public function test response with multiple cookies with multiheader()
@@ -1126,14 +1122,12 @@ public function test response with multiple cookies with multiheader()
             'multiValueHeaders' => [],
         ])['multiValueHeaders']['Set-Cookie'];
 
-        // If PHP 8.2 or greater, the formatting is slightly different for some reason
-        if (PHP_VERSION_ID >= 80200) {
-            self::assertEquals('MyCookie=FirstValue; expires=Fri, 12 Jan 2018 08:32:03 GMT; Max-Age=0; path=/hello/; domain=example.com; secure; HttpOnly', $cookieHeader[0]);
-            self::assertEquals('MyCookie=MyValue; expires=Fri, 12 Jan 2018 08:32:03 GMT; Max-Age=0; path=/hello/; domain=example.com; secure; HttpOnly', $cookieHeader[1]);
-        } else {
-            self::assertEquals('MyCookie=FirstValue; expires=Fri, 12-Jan-2018 08:32:03 GMT; Max-Age=0; path=/hello/; domain=example.com; secure; HttpOnly', $cookieHeader[0]);
-            self::assertEquals('MyCookie=MyValue; expires=Fri, 12-Jan-2018 08:32:03 GMT; Max-Age=0; path=/hello/; domain=example.com; secure; HttpOnly', $cookieHeader[1]);
-        }
+        // Dependending on the PHP version the date formatting is slightly different
+        $cookieHeader[0] = str_replace('12 Jan 2018', '12-Jan-2018', $cookieHeader[0]);
+        $cookieHeader[1] = str_replace('12 Jan 2018', '12-Jan-2018', $cookieHeader[1]);
+
+        self::assertEquals('MyCookie=FirstValue; expires=Fri, 12-Jan-2018 08:32:03 GMT; Max-Age=0; path=/hello/; domain=example.com; secure; HttpOnly', $cookieHeader[0]);
+        self::assertEquals('MyCookie=MyValue; expires=Fri, 12-Jan-2018 08:32:03 GMT; Max-Age=0; path=/hello/; domain=example.com; secure; HttpOnly', $cookieHeader[1]);
     }
 
     public function test response with error_log()
@@ -1152,7 +1146,7 @@ public function test response with error_log()
      */
     public function test FPM timeouts are recovered from()
     {
-        $this->fpm = new FpmHandler(__DIR__ . '/PhpFpm/timeout.php', __DIR__ . '/PhpFpm/php-fpm.conf');
+        $this->fpm = new FpmHandler(__DIR__ . '/fixtures/timeout.php', __DIR__ . '/fixtures/php-fpm.conf');
         $this->fpm->start();
 
         try {
@@ -1179,7 +1173,7 @@ public function test FPM timeouts are recovered from()
      */
     public function test worker logs are still written in case of a timeout()
     {
-        $this->fpm = new FpmHandler(__DIR__ . '/PhpFpm/timeout.php', __DIR__ . '/PhpFpm/php-fpm.conf');
+        $this->fpm = new FpmHandler(__DIR__ . '/fixtures/timeout.php', __DIR__ . '/fixtures/php-fpm.conf');
         $this->fpm->start();
 
         try {
@@ -1203,7 +1197,7 @@ public function test large response()
         for ($i = 0; $i < 5; $i++) {
             $response = $this->get('large-response.php');
 
-            self::assertStringEqualsFile(__DIR__ . '/PhpFpm/big-json.json', $response['body']);
+            self::assertStringEqualsFile(__DIR__ . '/fixtures/big-json.json', $response['body']);
         }
     }
 
@@ -1211,7 +1205,7 @@ public function test warmer events do not invoke the application()
     {
         // Run `timeout.php` to make sure that the handler is not really executed.
         // If it was, then PHP-FPM would timeout (and error).
-        $this->fpm = new FpmHandler(__DIR__ . '/PhpFpm/timeout.php', __DIR__ . '/PhpFpm/php-fpm.conf');
+        $this->fpm = new FpmHandler(__DIR__ . '/fixtures/timeout.php', __DIR__ . '/fixtures/php-fpm.conf');
         $this->fpm->start();
 
         $result = $this->fpm->handle([
@@ -1222,7 +1216,7 @@ public function test warmer events do not invoke the application()
 
     private function assertGlobalVariables(array $event, array $expectedGlobalVariables): void
     {
-        $this->startFpm(__DIR__ . '/PhpFpm/request.php');
+        $this->startFpm(__DIR__ . '/fixtures/request.php');
         $response = $this->fpm->handle($event, $this->fakeContext);
 
         $response = json_decode($response['body'], true);
@@ -1252,7 +1246,7 @@ private function assertCommonServerVariables(array $response, array $expectedGlo
             'REMOTE_PORT' => '80',
             'REMOTE_ADDR' => '127.0.0.1',
             'SERVER_SOFTWARE' => 'bref',
-            'SCRIPT_FILENAME' => __DIR__ . '/PhpFpm/request.php',
+            'SCRIPT_FILENAME' => __DIR__ . '/fixtures/request.php',
             'GATEWAY_INTERFACE' => 'FastCGI/1.0',
             'FCGI_ROLE' => 'RESPONDER',
         ];
@@ -1272,7 +1266,7 @@ private function assertCommonServerVariables(array $response, array $expectedGlo
 
     private function get(string $file, ?array $event = null): array
     {
-        $this->startFpm(__DIR__ . '/PhpFpm/' . $file);
+        $this->startFpm(__DIR__ . '/fixtures/' . $file);
 
         return $this->fpm->handle($event ?? [
             'version' => '1.0',
@@ -1285,7 +1279,7 @@ private function startFpm(string $handler): void
         if ($this->fpm) {
             $this->fpm->stop();
         }
-        $this->fpm = new FpmHandler($handler, __DIR__ . '/PhpFpm/php-fpm.conf');
+        $this->fpm = new FpmHandler($handler, __DIR__ . '/fixtures/php-fpm.conf');
         $this->fpm->start();
     }
 }
diff --git a/tests/Functional/FpmRuntimeTest.php b/tests/FpmRuntime/Functional/FpmRuntimeTest.php
similarity index 99%
rename from tests/Functional/FpmRuntimeTest.php
rename to tests/FpmRuntime/Functional/FpmRuntimeTest.php
index f89a85873..d0ff927a0 100644
--- a/tests/Functional/FpmRuntimeTest.php
+++ b/tests/FpmRuntime/Functional/FpmRuntimeTest.php
@@ -1,6 +1,6 @@
 <?php declare(strict_types=1);
 
-namespace Bref\Test\Functional;
+namespace Bref\Test\FpmRuntime\Functional;
 
 use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
 use GuzzleHttp\Client;
@@ -11,8 +11,7 @@ class FpmRuntimeTest extends TestCase
 {
     use ArraySubsetAsserts;
 
-    /** @var Client */
-    private $http;
+    private Client $http;
 
     public function setUp(): void
     {
diff --git a/tests/Functional/HttpApiTest.php b/tests/FpmRuntime/Functional/HttpApiTest.php
similarity index 80%
rename from tests/Functional/HttpApiTest.php
rename to tests/FpmRuntime/Functional/HttpApiTest.php
index ba7816abe..214d59444 100644
--- a/tests/Functional/HttpApiTest.php
+++ b/tests/FpmRuntime/Functional/HttpApiTest.php
@@ -1,17 +1,13 @@
 <?php declare(strict_types=1);
 
-namespace Bref\Test\Functional;
+namespace Bref\Test\FpmRuntime\Functional;
 
-use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
 use GuzzleHttp\Client;
 use PHPUnit\Framework\TestCase;
 
 class HttpApiTest extends TestCase
 {
-    use ArraySubsetAsserts;
-
-    /** @var Client */
-    private $http;
+    private Client $http;
 
     public function setUp(): void
     {
diff --git a/tests/Functional/fpm/index.php b/tests/FpmRuntime/Functional/fpm/index.php
similarity index 100%
rename from tests/Functional/fpm/index.php
rename to tests/FpmRuntime/Functional/fpm/index.php
diff --git a/tests/Functional/http-api/index.php b/tests/FpmRuntime/Functional/http-api/index.php
similarity index 100%
rename from tests/Functional/http-api/index.php
rename to tests/FpmRuntime/Functional/http-api/index.php
diff --git a/tests/serverless.tests.yml b/tests/FpmRuntime/Functional/serverless.yml
similarity index 89%
rename from tests/serverless.tests.yml
rename to tests/FpmRuntime/Functional/serverless.yml
index eb7ccaa78..07c108f43 100644
--- a/tests/serverless.tests.yml
+++ b/tests/FpmRuntime/Functional/serverless.yml
@@ -23,7 +23,7 @@ package:
 functions:
 
     fpm:
-        handler: tests/Functional/fpm/index.php
+        handler: fpm/index.php
         description: 'Bref FPM test'
         timeout: 5 # in seconds (API Gateway has a timeout of 29 seconds)
         reservedConcurrency: 5
@@ -35,7 +35,7 @@ functions:
             FOO: bar
 
     httpApi:
-        handler: tests/Functional/http-api/index.php
+        handler: http-api/index.php
         description: 'Bref HTTP API test'
         timeout: 5 # in seconds (API Gateway has a timeout of 29 seconds)
         reservedConcurrency: 5
diff --git a/tests/FpmRuntime/MainTest.php b/tests/FpmRuntime/MainTest.php
new file mode 100644
index 000000000..7c0aa5c16
--- /dev/null
+++ b/tests/FpmRuntime/MainTest.php
@@ -0,0 +1,21 @@
+<?php declare(strict_types=1);
+
+namespace Bref\Test\FpmRuntime;
+
+use Bref\Bref;
+use Bref\FpmRuntime\Main;
+use Exception;
+use PHPUnit\Framework\TestCase;
+
+class MainTest extends TestCase
+{
+    public function test startup hook is called()
+    {
+        Bref::beforeStartup(function () {
+            throw new Exception('This should be called');
+        });
+
+        $this->expectExceptionMessage('This should be called');
+        Main::run();
+    }
+}
diff --git a/tests/Handler/PhpFpm/alb-multi-value-query-string-encoded.json b/tests/FpmRuntime/fixtures/alb-multi-value-query-string-encoded.json
similarity index 100%
rename from tests/Handler/PhpFpm/alb-multi-value-query-string-encoded.json
rename to tests/FpmRuntime/fixtures/alb-multi-value-query-string-encoded.json
diff --git a/tests/Handler/PhpFpm/alb-multi-value-query-string-with-numeric-keys.json b/tests/FpmRuntime/fixtures/alb-multi-value-query-string-with-numeric-keys.json
similarity index 100%
rename from tests/Handler/PhpFpm/alb-multi-value-query-string-with-numeric-keys.json
rename to tests/FpmRuntime/fixtures/alb-multi-value-query-string-with-numeric-keys.json
diff --git a/tests/Handler/PhpFpm/alb-multi-value-query-string.json b/tests/FpmRuntime/fixtures/alb-multi-value-query-string.json
similarity index 100%
rename from tests/Handler/PhpFpm/alb-multi-value-query-string.json
rename to tests/FpmRuntime/fixtures/alb-multi-value-query-string.json
diff --git a/tests/Handler/PhpFpm/alb-query-string-encoded.json b/tests/FpmRuntime/fixtures/alb-query-string-encoded.json
similarity index 100%
rename from tests/Handler/PhpFpm/alb-query-string-encoded.json
rename to tests/FpmRuntime/fixtures/alb-query-string-encoded.json
diff --git a/tests/Handler/PhpFpm/alb-query-string-mixed-encoding.json b/tests/FpmRuntime/fixtures/alb-query-string-mixed-encoding.json
similarity index 100%
rename from tests/Handler/PhpFpm/alb-query-string-mixed-encoding.json
rename to tests/FpmRuntime/fixtures/alb-query-string-mixed-encoding.json
diff --git a/tests/Handler/PhpFpm/alb-query-string.json b/tests/FpmRuntime/fixtures/alb-query-string.json
similarity index 100%
rename from tests/Handler/PhpFpm/alb-query-string.json
rename to tests/FpmRuntime/fixtures/alb-query-string.json
diff --git a/tests/Handler/PhpFpm/big-json.json b/tests/FpmRuntime/fixtures/big-json.json
similarity index 100%
rename from tests/Handler/PhpFpm/big-json.json
rename to tests/FpmRuntime/fixtures/big-json.json
diff --git a/tests/Handler/PhpFpm/cookies.php b/tests/FpmRuntime/fixtures/cookies.php
similarity index 100%
rename from tests/Handler/PhpFpm/cookies.php
rename to tests/FpmRuntime/fixtures/cookies.php
diff --git a/tests/Handler/PhpFpm/error.php b/tests/FpmRuntime/fixtures/error.php
similarity index 100%
rename from tests/Handler/PhpFpm/error.php
rename to tests/FpmRuntime/fixtures/error.php
diff --git a/tests/Handler/PhpFpm/large-response.php b/tests/FpmRuntime/fixtures/large-response.php
similarity index 100%
rename from tests/Handler/PhpFpm/large-response.php
rename to tests/FpmRuntime/fixtures/large-response.php
diff --git a/tests/Handler/PhpFpm/php-fpm.conf b/tests/FpmRuntime/fixtures/php-fpm.conf
similarity index 100%
rename from tests/Handler/PhpFpm/php-fpm.conf
rename to tests/FpmRuntime/fixtures/php-fpm.conf
diff --git a/tests/Handler/PhpFpm/request.php b/tests/FpmRuntime/fixtures/request.php
similarity index 100%
rename from tests/Handler/PhpFpm/request.php
rename to tests/FpmRuntime/fixtures/request.php
diff --git a/tests/Handler/PhpFpm/response-headers.php b/tests/FpmRuntime/fixtures/response-headers.php
similarity index 100%
rename from tests/Handler/PhpFpm/response-headers.php
rename to tests/FpmRuntime/fixtures/response-headers.php
diff --git a/tests/Handler/PhpFpm/status-code.php b/tests/FpmRuntime/fixtures/status-code.php
similarity index 100%
rename from tests/Handler/PhpFpm/status-code.php
rename to tests/FpmRuntime/fixtures/status-code.php
diff --git a/tests/Handler/PhpFpm/timeout.php b/tests/FpmRuntime/fixtures/timeout.php
similarity index 100%
rename from tests/Handler/PhpFpm/timeout.php
rename to tests/FpmRuntime/fixtures/timeout.php
diff --git a/tests/FunctionRuntime/MainTest.php b/tests/FunctionRuntime/MainTest.php
new file mode 100644
index 000000000..97fe60eaf
--- /dev/null
+++ b/tests/FunctionRuntime/MainTest.php
@@ -0,0 +1,21 @@
+<?php declare(strict_types=1);
+
+namespace Bref\Test\FunctionRuntime;
+
+use Bref\Bref;
+use Bref\FunctionRuntime\Main;
+use Exception;
+use PHPUnit\Framework\TestCase;
+
+class MainTest extends TestCase
+{
+    public function test startup hook is called()
+    {
+        Bref::beforeStartup(function () {
+            throw new Exception('This should be called');
+        });
+
+        $this->expectExceptionMessage('This should be called');
+        Main::run();
+    }
+}
diff --git a/tests/Runtime/FileHandlerLocatorTest.php b/tests/Runtime/FileHandlerLocatorTest.php
index c055a5ed7..fe5eb9d50 100644
--- a/tests/Runtime/FileHandlerLocatorTest.php
+++ b/tests/Runtime/FileHandlerLocatorTest.php
@@ -27,4 +27,11 @@ public function test expects file to return handler()
 
         $locator->get('broken.php');
     }
+
+    public function test accepts empty directory()
+    {
+        $locator = new FileHandlerLocator;
+
+        $this->assertTrue($locator->has('tests/BrefTest.php'));
+    }
 }
diff --git a/tests/Runtime/LambdaRuntimeTest.php b/tests/Runtime/LambdaRuntimeTest.php
index bf1d6f12d..911c2b750 100644
--- a/tests/Runtime/LambdaRuntimeTest.php
+++ b/tests/Runtime/LambdaRuntimeTest.php
@@ -15,11 +15,13 @@
 use Bref\Event\Sqs\SqsHandler;
 use Bref\Runtime\LambdaRuntime;
 use Bref\Test\Server;
+use Exception;
 use GuzzleHttp\Psr7\Response;
 use PHPUnit\Framework\TestCase;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Server\RequestHandlerInterface;
+use RuntimeException;
 
 /**
  * Tests the communication between `LambdaRuntime` and the Lambda Runtime HTTP API.
@@ -28,8 +30,7 @@
  */
 class LambdaRuntimeTest extends TestCase
 {
-    /** @var LambdaRuntime */
-    private $runtime;
+    private LambdaRuntime $runtime;
 
     protected function setUp(): void
     {
@@ -75,7 +76,7 @@ public function test exceptions in the handler result in an invocation 
         $this->givenAnEvent(['Hello' => 'world!']);
 
         $output = $this->runtime->processNextEvent(function () {
-            throw new \RuntimeException('This is an exception');
+            throw new RuntimeException('This is an exception');
         });
 
         $this->assertFalse($output);
@@ -88,7 +89,7 @@ public function test nested exceptions in the handler result in an invo
         $this->givenAnEvent(['Hello' => 'world!']);
 
         $this->runtime->processNextEvent(function () {
-            throw new \RuntimeException('This is an exception', 0, new \RuntimeException('The previous exception.', 0, new \Exception('The original exception.')));
+            throw new RuntimeException('This is an exception', 0, new RuntimeException('The previous exception.', 0, new Exception('The original exception.')));
         });
 
         $this->assertInvocationErrorResult('RuntimeException', 'This is an exception');
@@ -176,7 +177,7 @@ public function test a wrong response from the runtime API turns the i
         $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/1/error', $eventFailureLog->getUri()->__toString());
 
         // Check the lambda result contains the error message
-        $error = json_decode((string) $eventFailureLog->getBody(), true);
+        $error = json_decode((string) $eventFailureLog->getBody(), true, 512, JSON_THROW_ON_ERROR);
         $this->assertStringContainsString('Error while calling the Lambda runtime API: The requested URL returned error: 400', $error['errorMessage']);
 
         $this->assertErrorInLogs('Exception', 'Error while calling the Lambda runtime API: The requested URL returned error: 400');
@@ -202,11 +203,7 @@ public function test function results that cannot be encoded are reporte
     public function test generic event handler()
     {
         $handler = new class() implements Handler {
-            /**
-             * @param mixed $event
-             * @return mixed
-             */
-            public function handle($event, Context $context)
+            public function handle(mixed $event, Context $context): mixed
             {
                 return $event;
             }
@@ -222,15 +219,14 @@ public function handle($event, Context $context)
     public function test SQS event handler()
     {
         $handler = new class() extends SqsHandler {
-            /** @var SqsEvent */
-            public $event;
+            public SqsEvent $event;
             public function handleSqs(SqsEvent $event, Context $context): void
             {
                 $this->event = $event;
             }
         };
 
-        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/Sqs/sqs.json'), true);
+        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/Sqs/sqs.json'), true, 512, JSON_THROW_ON_ERROR);
         $this->givenAnEvent($eventData);
 
         $this->runtime->processNextEvent($handler);
@@ -241,15 +237,14 @@ public function handleSqs(SqsEvent $event, Context $context): void
     public function test SNS event handler()
     {
         $handler = new class() extends SnsHandler {
-            /** @var SnsEvent */
-            public $event;
+            public SnsEvent $event;
             public function handleSns(SnsEvent $event, Context $context): void
             {
                 $this->event = $event;
             }
         };
 
-        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/Sns/sns.json'), true);
+        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/Sns/sns.json'), true, 512, JSON_THROW_ON_ERROR);
         $this->givenAnEvent($eventData);
 
         $this->runtime->processNextEvent($handler);
@@ -260,15 +255,14 @@ public function handleSns(SnsEvent $event, Context $context): void
     public function test S3 event handler()
     {
         $handler = new class() extends S3Handler {
-            /** @var S3Event */
-            public $event;
+            public S3Event $event;
             public function handleS3(S3Event $event, Context $context): void
             {
                 $this->event = $event;
             }
         };
 
-        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/S3/s3.json'), true);
+        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/S3/s3.json'), true, 512, JSON_THROW_ON_ERROR);
         $this->givenAnEvent($eventData);
 
         $this->runtime->processNextEvent($handler);
@@ -279,8 +273,7 @@ public function handleS3(S3Event $event, Context $context): void
     public function test PSR15 event handler()
     {
         $handler = new class() implements RequestHandlerInterface {
-            /** @var ServerRequestInterface */
-            public $request;
+            public ServerRequestInterface $request;
             public function handle(ServerRequestInterface $request): ResponseInterface
             {
                 $this->request = $request;
@@ -290,7 +283,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface
             }
         };
 
-        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/Http/Fixture/ag-v1-simple.json'), true);
+        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/Http/Fixture/ag-v1-simple.json'), true, 512, JSON_THROW_ON_ERROR);
         $this->givenAnEvent($eventData);
 
         $this->runtime->processNextEvent($handler);
@@ -312,15 +305,14 @@ public function handle(ServerRequestInterface $request): ResponseInterface
     public function test EventBridge event handler()
     {
         $handler = new class() extends EventBridgeHandler {
-            /** @var EventBridgeEvent */
-            public $event;
+            public EventBridgeEvent $event;
             public function handleEventBridge(EventBridgeEvent $event, Context $context): void
             {
                 $this->event = $event;
             }
         };
 
-        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/EventBridge/eventbridge.json'), true);
+        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/EventBridge/eventbridge.json'), true, 512, JSON_THROW_ON_ERROR);
         $this->givenAnEvent($eventData);
 
         $this->runtime->processNextEvent($handler);
@@ -328,21 +320,7 @@ public function handleEventBridge(EventBridgeEvent $event, Context $context): vo
         $this->assertEquals(new EventBridgeEvent($eventData), $handler->event);
     }
 
-    public function test invalid handlers are rejected properly()
-    {
-        $eventData = json_decode(file_get_contents(__DIR__ . '/../Event/Http/Fixture/ag-v1-simple.json'), true);
-        $this->givenAnEvent($eventData);
-
-        $this->runtime->processNextEvent(null);
-
-        $this->assertInvocationErrorResult('Exception', 'The lambda handler must be a callable or implement handler interfaces');
-        $this->assertErrorInLogs('Exception', 'The lambda handler must be a callable or implement handler interfaces');
-    }
-
-    /**
-     * @param mixed $event
-     */
-    private function givenAnEvent($event): void
+    private function givenAnEvent(mixed $event): void
     {
         Server::enqueue([
             new Response( // lambda event
@@ -351,16 +329,13 @@ private function givenAnEvent($event): void
                     'lambda-runtime-aws-request-id' => '1',
                     'lambda-runtime-invoked-function-arn' => 'test-function-name',
                 ],
-                json_encode($event)
+                json_encode($event, JSON_THROW_ON_ERROR)
             ),
             new Response(200), // lambda response accepted
         ]);
     }
 
-    /**
-     * @param mixed $result
-     */
-    private function assertInvocationResult($result)
+    private function assertInvocationResult(mixed $result)
     {
         $requests = Server::received();
         $this->assertCount(2, $requests);
@@ -370,7 +345,7 @@ private function assertInvocationResult($result)
         $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/next', $eventRequest->getUri()->__toString());
         $this->assertSame('POST', $eventResponse->getMethod());
         $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/1/response', $eventResponse->getUri()->__toString());
-        $this->assertEquals($result, json_decode($eventResponse->getBody()->__toString(), true));
+        $this->assertEquals($result, json_decode($eventResponse->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR));
     }
 
     private function assertInvocationErrorResult(string $errorClass, string $errorMessage)
@@ -385,7 +360,7 @@ private function assertInvocationErrorResult(string $errorClass, string $errorMe
         $this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/1/error', $eventResponse->getUri()->__toString());
 
         // Check the content of the result of the lambda
-        $invocationResult = json_decode($eventResponse->getBody()->__toString(), true);
+        $invocationResult = json_decode($eventResponse->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);
         $this->assertSame([
             'errorType',
             'errorMessage',
@@ -408,7 +383,7 @@ private function assertErrorInLogs(string $errorClass, string $errorMessage): vo
         // Check the request ID matches a UUID
         $this->assertNotEmpty($requestId);
 
-        $invocationResult = json_decode($json, true);
+        $invocationResult = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
         unset($invocationResult['previous']);
         $this->assertSame([
             'errorType',
@@ -416,7 +391,7 @@ private function assertErrorInLogs(string $errorClass, string $errorMessage): vo
             'stack',
         ], array_keys($invocationResult));
         $this->assertEquals($errorClass, $invocationResult['errorType']);
-        $this->assertEquals($errorMessage, $invocationResult['errorMessage']);
+        $this->assertStringContainsString($errorMessage, $invocationResult['errorMessage']);
         $this->assertIsArray($invocationResult['stack']);
     }
 
@@ -427,7 +402,7 @@ private function assertPreviousErrorsInLogs(array $previousErrors)
 
         [, , $json] = explode("\t", $stdout);
 
-        ['previous' => $previous] = json_decode($json, true);
+        ['previous' => $previous] = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
         $this->assertCount(count($previousErrors), $previous);
         foreach ($previous as $index => $error) {
             $this->assertSame([
diff --git a/tests/Sam/PhpFpmRuntimeTest.php b/tests/Sam/PhpFpmRuntimeTest.php
index 8e3c0d014..24e955e4d 100644
--- a/tests/Sam/PhpFpmRuntimeTest.php
+++ b/tests/Sam/PhpFpmRuntimeTest.php
@@ -14,8 +14,7 @@
  */
 class PhpFpmRuntimeTest extends TestCase
 {
-    /** @var string */
-    private $logs;
+    private string $logs;
 
     public function setUp(): void
     {
diff --git a/tests/Sam/Psr7Test.php b/tests/Sam/Psr7Test.php
index 33056c027..a31345bb7 100644
--- a/tests/Sam/Psr7Test.php
+++ b/tests/Sam/Psr7Test.php
@@ -9,8 +9,7 @@
 
 class Psr7Test extends TestCase
 {
-    /** @var string */
-    private $logs;
+    private string $logs;
 
     public function setUp(): void
     {
diff --git a/tests/Sam/template.yaml b/tests/Sam/template.yaml
index d4d723ac8..7e262270f 100644
--- a/tests/Sam/template.yaml
+++ b/tests/Sam/template.yaml
@@ -10,7 +10,7 @@ Resources:
             Handler: tests/Sam/Php/function.php
             Runtime: provided.al2
             Layers:
-                - 'arn:aws:lambda:us-east-1:209497400698:layer:php-74:18'
+                - 'arn:aws:lambda:us-east-1:534081306603:layer:php-74:18'
             Environment:
                 Variables:
                     FOO: bar
@@ -23,7 +23,7 @@ Resources:
             Handler: tests/Sam/PhpFpm/index.php
             Runtime: provided.al2
             Layers:
-                - 'arn:aws:lambda:us-east-1:209497400698:layer:php-74-fpm:18'
+                - 'arn:aws:lambda:us-east-1:534081306603:layer:php-74-fpm:18'
             Events:
                 HttpRoot:
                     Type: Api
@@ -42,7 +42,7 @@ Resources:
             Handler: tests/Sam/PhpFpm/UNKNOWN.php
             Runtime: provided.al2
             Layers:
-                - 'arn:aws:lambda:us-east-1:209497400698:layer:php-74-fpm:18'
+                - 'arn:aws:lambda:us-east-1:534081306603:layer:php-74-fpm:18'
             Events:
                 HttpRoot:
                     Type: Api
@@ -58,7 +58,7 @@ Resources:
             Handler: tests/Sam/Php/psr7.php
             Runtime: provided.al2
             Layers:
-                - 'arn:aws:lambda:us-east-1:209497400698:layer:php-74:18'
+                - 'arn:aws:lambda:us-east-1:534081306603:layer:php-74:18'
             Events:
                 HttpRoot:
                     Type: Api
diff --git a/tests/Server.php b/tests/Server.php
index 1ecc7b6d6..b950737e4 100644
--- a/tests/Server.php
+++ b/tests/Server.php
@@ -45,14 +45,10 @@
  */
 class Server
 {
-    /** @var Client|null */
-    private static $client;
-    /** @var bool */
-    private static $started = false;
-    /** @var string */
-    public static $url = 'http://127.0.0.1:8126/';
-    /** @var int */
-    public static $port = 8126;
+    private static ?Client $client = null;
+    private static bool $started = false;
+    public static string $url = 'http://127.0.0.1:8126/';
+    public static int $port = 8126;
 
     /**
      * Flush the received requests from the server
diff --git a/tests/Websocket/Samples/disconnect-failure-gone.json b/tests/Websocket/Samples/disconnect-failure-gone.json
deleted file mode 100644
index 9fdd83693..000000000
--- a/tests/Websocket/Samples/disconnect-failure-gone.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
-  "headers": {
-    "date": [
-      "Sun, 15 Nov 2020 12:23:15 GMT"
-    ],
-    "content-type": [
-      "application/json"
-    ],
-    "content-length": [
-      "0"
-    ],
-    "x-amzn-requestid": [
-      "cf8e240a-5720-46d2-a107-9bec10810997"
-    ],
-    "x-amzn-errortype": [
-      "GoneException"
-    ],
-    "x-amz-apigw-id": [
-      "WDGT_G9OjoEFlFg="
-    ],
-    "x-amzn-trace-id": [
-      "Root=1-5fb11db2-4c6d6e4e52cfa74307320555"
-    ]
-  },
-  "body": "",
-  "status_code": 410
-}
\ No newline at end of file
diff --git a/tests/Websocket/Samples/disconnect-success.json b/tests/Websocket/Samples/disconnect-success.json
deleted file mode 100644
index b7eb21623..000000000
--- a/tests/Websocket/Samples/disconnect-success.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
-  "headers": {
-    "date": [
-      "Sun, 15 Nov 2020 12:23:14 GMT"
-    ],
-    "x-amzn-requestid": [
-      "ae8ad4b3-af3b-4127-8341-14ac9ef40265"
-    ],
-    "x-amz-apigw-id": [
-      "WDGT-EdpDoEF5ow="
-    ],
-    "x-amzn-trace-id": [
-      "Root=1-5fb11db2-371b0a1e4a6434492d8d2d56"
-    ]
-  },
-  "body": "",
-  "status_code": 204
-}
\ No newline at end of file
diff --git a/tests/Websocket/Samples/message-failure-invalid-connectionid.json b/tests/Websocket/Samples/message-failure-invalid-connectionid.json
deleted file mode 100644
index d62e28cd7..000000000
--- a/tests/Websocket/Samples/message-failure-invalid-connectionid.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
-  "headers": {
-    "date": [
-      "Sun, 15 Nov 2020 12:01:16 GMT"
-    ],
-    "content-type": [
-      "application/json"
-    ],
-    "content-length": [
-      "56"
-    ],
-    "x-amzn-requestid": [
-      "672327e6-b7b7-47c9-9a96-774b6169823f"
-    ],
-    "x-amzn-errortype": [
-      "BadRequestException"
-    ],
-    "x-amz-apigw-id": [
-      "WDDGAGCBjoEFmsQ="
-    ]
-  },
-  "body": "{\"message\":\"Invalid connectionId: WDGT8fY4joECJrQ=test\"}",
-  "status_code": 400
-}
\ No newline at end of file
diff --git a/tests/Websocket/Samples/message-success.json b/tests/Websocket/Samples/message-success.json
deleted file mode 100644
index 798e71b95..000000000
--- a/tests/Websocket/Samples/message-success.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
-  "headers": {
-    "date": [
-      "Sun, 15 Nov 2020 12:23:14 GMT"
-    ],
-    "content-type": [
-      "application/json"
-    ],
-    "content-length": [
-      "0"
-    ],
-    "x-amzn-requestid": [
-      "03c09e84-9949-48d9-9d50-8ddb489d6142"
-    ],
-    "x-amz-apigw-id": [
-      "WDGT-HXEjoEFjYQ="
-    ],
-    "x-amzn-trace-id": [
-      "Root=1-5fb11db2-0cedbd927fcf897126a48be5"
-    ]
-  },
-  "body": "",
-  "status_code": "200"
-}
\ No newline at end of file
diff --git a/tests/Websocket/Samples/status.json b/tests/Websocket/Samples/status.json
deleted file mode 100644
index 8c3fa62e2..000000000
--- a/tests/Websocket/Samples/status.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
-  "headers": {
-    "date": [
-      "Sun, 15 Nov 2020 12:23:14 GMT"
-    ],
-    "content-type": [
-      "application/json"
-    ],
-    "content-length": [
-      "286"
-    ],
-    "x-amzn-requestid": [
-      "3897b3c1-de78-4da1-8721-4130d1ace0ec"
-    ],
-    "x-amz-apigw-id": [
-      "WDGT-Gy-DoEFoaQ="
-    ],
-    "x-amzn-trace-id": [
-      "Root=1-5fb11db2-39a99bd67c3a4b7c260e8043"
-    ]
-  },
-  "body": "{\n  \"identity\" : {\n    \"sourceIp\" : \"1.2.3.4\",\n    \"userAgent\" : \"Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/86.0.4240.198 Safari\/537.36\"\n  },\n  \"connectedAt\" : \"2020-11-15T12:23:14.726Z\",\n  \"lastActiveAt\" : \"2020-11-15T12:23:14.972Z\"\n}",
-  "status_code": 200
-}
\ No newline at end of file
diff --git a/tests/Websocket/WebsocketClientTest.php b/tests/Websocket/WebsocketClientTest.php
deleted file mode 100644
index 4adcf76ca..000000000
--- a/tests/Websocket/WebsocketClientTest.php
+++ /dev/null
@@ -1,90 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Bref\Test\Websocket;
-
-use AsyncAws\Core\Credentials\NullProvider;
-use AsyncAws\Core\Exception\Http\ClientException;
-use Bref\Websocket\SimpleWebsocketClient;
-use PHPUnit\Framework\TestCase;
-use Symfony\Component\HttpClient\MockHttpClient;
-use Symfony\Component\HttpClient\Response\MockResponse;
-
-class WebsocketClientTest extends TestCase
-{
-    public function test message success()
-    {
-        $request = json_decode(file_get_contents(__DIR__ . '/Samples/message-success.json'), true);
-        $client = $this->getClient($request);
-
-        $client->message('WDGT8fY4joECJrQ=', 'ping');
-        $this->assertTrue(true, 'Assertion to mark that no exception was thrown');
-    }
-
-    public function test message failure()
-    {
-        $request = json_decode(file_get_contents(__DIR__ . '/Samples/message-failure-invalid-connectionid.json'), true);
-        $client = $this->getClient($request);
-
-        $this->expectException(ClientException::class);
-        $this->expectExceptionMessage('Invalid connectionId: WDGT8fY4joECJrQ=test');
-        $this->expectExceptionCode(400);
-        $client->message('WDGT8fY4joECJrQ=test', 'ping');
-    }
-
-    public function test disconnect success()
-    {
-        $request = json_decode(file_get_contents(__DIR__ . '/Samples/disconnect-success.json'), true);
-        $client = $this->getClient($request);
-
-        $client->disconnect('WDGT8fY4joECJrQ=test');
-        $this->assertTrue(true, 'Assertion to mark that no exception was thrown');
-    }
-
-    public function test disconnect failure()
-    {
-        $request = json_decode(file_get_contents(__DIR__ . '/Samples/disconnect-failure-gone.json'), true);
-        $client = $this->getClient($request);
-
-        $this->expectException(ClientException::class);
-        $this->expectExceptionCode(410);
-        $client->disconnect('WDGT8fY4joECJrQ=test');
-    }
-
-    public function test status()
-    {
-        $request = json_decode(file_get_contents(__DIR__ . '/Samples/status.json'), true);
-        $client = $this->getClient($request);
-
-        $status = $client->status('WDGT8fY4joECJrQ=');
-        $this->assertSame('1.2.3.4', $status->getSourceIp());
-        $this->assertSame('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36', $status->getUserAgent());
-        $this->assertSame(1605442994, $status->getConnectedAt()->getTimestamp());
-        $this->assertSame(1605442994, $status->getLastActiveAt()->getTimestamp());
-
-        $info = $status->toArray();
-        $this->assertArrayHasKey('sourceIp', $info);
-        $this->assertArrayHasKey('userAgent', $info);
-        $this->assertArrayHasKey('connectedAt', $info);
-        $this->assertArrayHasKey('lastActiveAt', $info);
-    }
-
-    /**
-     * Construct a SimpleWebsocketClient from sample request.
-     */
-    private function getClient(array $request): SimpleWebsocketClient
-    {
-        return new SimpleWebsocketClient(
-            'apiId',
-            'eu-west-1',
-            'dev',
-            new MockHttpClient(
-                [
-                    new MockResponse($request['body'], [
-                        'http_code' => $request['status_code'],
-                    ]),
-                ]
-            ),
-            new NullProvider
-        );
-    }
-}