Repository: shlinkio/shlink (php)

Issue:
Consider supporting roadrunner as an execution context
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

* For mezzio: https://github.com/bcremer/roadrunner-mezzio-integration
* Docs: https://roadrunner.dev/docs
* Jobs: https://roadrunner.dev/docs/plugins-jobs/2.x/en
* CLI options: https://roadrunner.dev/docs/app-server-cli/2.x/en

---

- [x] Support dispatching web requests via RoadRunner.
- [x] Support processing async events via RoadRunner jobs.
- [x] Allow publishing a version of the docker image for RoadRunner instead of swoole.
- [x] Generate dist files for RoadRunner
  - [x] Do not install mezzio/mezzio-swoole when the target is road-runner (maybe the non-swoole one can be re-used)
  - [x] Do not install spiral/roadrunner and spiral/roadrunner-jobs when the target is openswoole
- [x] ~Generate access and regular logs in the same format as without RoadRunner.~ Not possible
- [x] Support serving static files.
- [x] Run API tests both with openswoole and RoadRunner
  - [x] In CI too.
- [x] Enable file reloading on development env
- [ ] Document how to use RoadRunner for those not using the docker image

Pull request:
Feature/roadrunner support
Closes #1221 

Patch:
diff --git a/.dockerignore b/.dockerignore
index 870f3610..beca6373 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,4 @@
+bin/rr
 config/autoload/*local*
 data/infra
 data/cache/*
@@ -22,4 +23,4 @@ infection*
 **/test*
 build*
 **/.*
-bin/helper
+!config/roadrunner/.rr.yml
diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml
index eb7c8979..78cbdf1c 100644
--- a/.github/actions/ci-setup/action.yml
+++ b/.github/actions/ci-setup/action.yml
@@ -11,7 +11,7 @@ inputs:
     required: true
   php-extensions:
     description: 'The PHP extensions to install'
-    required: true
+    required: false
     default: ''
   extensions-cache-key:
     description: 'The key used to cache PHP extensions. If empty value is provided, extension caching is disabled'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 57985e8a..f9d3660e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,15 +29,31 @@ jobs:
     with:
       test-group: unit
 
-  api-tests:
+  cli-tests:
     uses: './.github/workflows/ci-tests.yml'
     with:
-      test-group: api
+      test-group: cli
 
-  cli-tests:
+  openswoole-api-tests:
     uses: './.github/workflows/ci-tests.yml'
     with:
-      test-group: cli
+      test-group: api
+
+  roadrunner-api-tests:
+    runs-on: ubuntu-22.04
+    strategy:
+      matrix:
+        php-version: [ '8.1' ]
+    steps:
+      - uses: actions/checkout@v3
+      - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
+      - uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php-version }}
+          tools: composer
+      - run: composer install --no-interaction --prefer-dist
+      - run: ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr
+      - run: composer test:api:rr
 
   sqlite-db-tests:
     uses: './.github/workflows/ci-db-tests.yml'
@@ -80,7 +96,7 @@ jobs:
 
   api-mutation-tests:
     needs:
-      - api-tests
+      - openswoole-api-tests
     uses: './.github/workflows/ci-mutation-tests.yml'
     with:
       test-group: api
@@ -95,7 +111,7 @@ jobs:
   upload-coverage:
     needs:
       - unit-tests
-      - api-tests
+      - openswoole-api-tests
       - cli-tests
       - sqlite-db-tests
     runs-on: ubuntu-22.04
diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/docker-image-build.yml
index cbd8b213..11396f3f 100644
--- a/.github/workflows/docker-image-build.yml
+++ b/.github/workflows/docker-image-build.yml
@@ -8,9 +8,19 @@ on:
       - 'v*'
 
 jobs:
-  build:
+  build-openswool:
     uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
     secrets: inherit
     with:
       image-name: shlinkio/shlink
       version-arg-name: SHLINK_VERSION
+
+  build-roadrunner:
+    uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
+    secrets: inherit
+    with:
+      image-name: shlinkio/shlink
+      version-arg-name: SHLINK_VERSION
+      tags-suffix: roadrunner
+      extra-build-args: |
+        SHLINK_RUNTIME=rr
diff --git a/.gitignore b/.gitignore
index 933c25ee..daea5f2f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
 .idea
+bin/.rr.*
+bin/rr
+config/roadrunner/.pid
 build
 !docker/build
 composer.lock
diff --git a/Dockerfile b/Dockerfile
index 2944db45..2835d75f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,6 +2,8 @@ FROM php:8.1.9-alpine3.16 as base
 
 ARG SHLINK_VERSION=latest
 ENV SHLINK_VERSION ${SHLINK_VERSION}
+ARG SHLINK_RUNTIME=openswoole
+ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
 ENV OPENSWOOLE_VERSION 4.11.1
 ENV PDO_SQLSRV_VERSION 5.10.1
 ENV MS_ODBC_SQL_VERSION 17.5.2.2
@@ -22,8 +24,10 @@ RUN \
 
 # Install openswoole and sqlsrv driver for x86_64 builds
 RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
-    pecl install openswoole-${OPENSWOOLE_VERSION} && \
-    docker-php-ext-enable openswoole && \
+    if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
+        pecl install openswoole-${OPENSWOOLE_VERSION} && \
+        docker-php-ext-enable openswoole ; \
+    fi; \
     if [ $(uname -m) == "x86_64" ]; then \
       wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
       apk add --no-cache --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
@@ -38,7 +42,12 @@ FROM base as builder
 COPY . .
 COPY --from=composer:2 /usr/bin/composer ./composer.phar
 RUN apk add --no-cache git && \
-    php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \
+    php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \
+    if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
+        php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interactionc ; \
+    elif [ $SHLINK_RUNTIME == 'rr' ]; then \
+        php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \
+    fi; \
     php composer.phar clear-cache && \
     rm -r docker composer.* && \
     sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
@@ -49,9 +58,12 @@ FROM base
 LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
 
 COPY --from=builder /etc/shlink .
-RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
+RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink && \
+    if [ "$SHLINK_RUNTIME" == 'rr' ]; then \
+      php ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr ; \
+    fi;
 
-# Expose default openswoole port
+# Expose default port
 EXPOSE 8080
 
 # Copy config specific for the image
diff --git a/README.md b/README.md
index 1fe3b89c..bb99634e 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
 
 - [Full documentation](#full-documentation)
 - [Docker image](#docker-image)
-- [Self hosted](#self-hosted)
+- [Self-hosted](#self-hosted)
     - [Download](#download)
     - [Configure](#configure)
 - [Using shlink](#using-shlink)
diff --git a/bin/roadrunner-worker.php b/bin/roadrunner-worker.php
new file mode 100644
index 00000000..c4a89a85
--- /dev/null
+++ b/bin/roadrunner-worker.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+use Mezzio\Application;
+use Psr\Container\ContainerInterface;
+use Shlinkio\Shlink\EventDispatcher\RoadRunner\RoadRunnerTaskConsumerToListener;
+use Spiral\RoadRunner\Http\PSR7Worker;
+
+use function Shlinkio\Shlink\Config\env;
+
+(static function (): void {
+    /** @var ContainerInterface $container */
+    $container = include __DIR__ . '/../config/container.php';
+    $rrMode = env('RR_MODE');
+
+    if ($rrMode === 'http') {
+        // This was spin-up as a web worker
+        $app = $container->get(Application::class);
+        $worker = $container->get(PSR7Worker::class);
+
+        while ($req = $worker->waitRequest()) {
+            try {
+                $worker->respond($app->handle($req));
+            } catch (Throwable $e) {
+                $worker->getWorker()->error((string) $e);
+            }
+        }
+    } else {
+        $container->get(RoadRunnerTaskConsumerToListener::class)->listenForTasks();
+    }
+})();
diff --git a/build.sh b/build.sh
index e274210a..d9cda64d 100755
--- a/build.sh
+++ b/build.sh
@@ -24,6 +24,7 @@ rsync -av * "${builtContent}" \
     --exclude=*docker* \
     --exclude=Dockerfile \
     --include=.htaccess \
+    --include=config/roadrunner/.rr.yml \
     --exclude-from=./.dockerignore
 cd "${builtContent}"
 
@@ -36,6 +37,9 @@ ${composerBin} install --no-dev --prefer-dist $composerFlags
 if [[ $noSwoole ]]; then
   # If generating a dist not for openswoole, uninstall mezzio-swoole
   ${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags
+else
+  # If generating a dist for openswoole, uninstall RoadRunner
+  ${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs --with-all-dependencies --update-no-dev $composerFlags
 fi
 
 # Delete development files
diff --git a/composer.json b/composer.json
index 168418a5..d8b2874f 100644
--- a/composer.json
+++ b/composer.json
@@ -44,11 +44,13 @@
         "pugx/shortid-php": "^1.0",
         "ramsey/uuid": "^4.3",
         "shlinkio/shlink-common": "^5.0",
-        "shlinkio/shlink-config": "^2.0",
-        "shlinkio/shlink-event-dispatcher": "^2.5",
+        "shlinkio/shlink-config": "dev-main#33004e6 as 2.1",
+        "shlinkio/shlink-event-dispatcher": "dev-main#48c0137 as 2.6",
         "shlinkio/shlink-importer": "^4.0",
         "shlinkio/shlink-installer": "^8.1",
         "shlinkio/shlink-ip-geolocation": "^3.0",
+        "spiral/roadrunner": "^2.11",
+        "spiral/roadrunner-jobs": "^2.3",
         "symfony/console": "^6.1",
         "symfony/filesystem": "^6.1",
         "symfony/lock": "^6.1",
@@ -120,6 +122,7 @@
         "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
         "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
         "test:api": "bin/test/run-api-tests.sh",
+        "test:api:rr": "TEST_RUNTIME=rr bin/test/run-api-tests.sh",
         "test:api:ci": "GENERATE_COVERAGE=yes composer test:api",
         "test:api:pretty": "GENERATE_COVERAGE=pretty composer test:api",
         "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml",
diff --git a/config/autoload/dependencies.global.php b/config/autoload/dependencies.global.php
index dbc553f1..657caffb 100644
--- a/config/autoload/dependencies.global.php
+++ b/config/autoload/dependencies.global.php
@@ -3,12 +3,22 @@
 declare(strict_types=1);
 
 use GuzzleHttp\Client;
+use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
 use Mezzio\Container;
 use Psr\Http\Client\ClientInterface;
+use Psr\Http\Message\ServerRequestFactoryInterface;
+use Psr\Http\Message\StreamFactoryInterface;
+use Psr\Http\Message\UploadedFileFactoryInterface;
+use Spiral\RoadRunner\Http\PSR7Worker;
+use Spiral\RoadRunner\WorkerInterface;
 
 return [
 
     'dependencies' => [
+        'factories' => [
+            PSR7Worker::class => ConfigAbstractFactory::class,
+        ],
+
         'delegators' => [
             Mezzio\Application::class => [
                 Container\ApplicationConfigInjectionDelegator::class,
@@ -26,4 +36,13 @@ return [
         ],
     ],
 
+    ConfigAbstractFactory::class => [
+        PSR7Worker::class => [
+            WorkerInterface::class,
+            ServerRequestFactoryInterface::class,
+            StreamFactoryInterface::class,
+            UploadedFileFactoryInterface::class,
+        ],
+    ],
+
 ];
diff --git a/config/autoload/mercure.local.php.dist b/config/autoload/mercure.local.php.dist
index b10ad86e..e818404b 100644
--- a/config/autoload/mercure.local.php.dist
+++ b/config/autoload/mercure.local.php.dist
@@ -7,7 +7,7 @@ return [
     'mercure' => [
         'public_hub_url' => 'http://localhost:8001',
         'internal_hub_url' => 'http://shlink_mercure_proxy',
-        'jwt_secret' => 'mercure_jwt_key',
+        'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error',
     ],
 
 ];
diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist
index 0069ffa9..f49570e1 100644
--- a/config/autoload/url-shortener.local.php.dist
+++ b/config/autoload/url-shortener.local.php.dist
@@ -2,14 +2,19 @@
 
 declare(strict_types=1);
 
-$isSwoole = extension_loaded('openswoole');
+use function Shlinkio\Shlink\Config\runningInOpenswoole;
+use function Shlinkio\Shlink\Config\runningInRoadRunner;
 
 return [
 
     'url_shortener' => [
         'domain' => [
             'schema' => 'http',
-            'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'),
+            'hostname' => sprintf('localhost:%s', match (true) {
+                runningInRoadRunner() => '8800',
+                runningInOpenswoole() => '8080',
+                default => '8000',
+            }),
         ],
         'auto_resolve_titles' => true,
 //        'multi_segment_slugs_enabled' => true,
diff --git a/config/config.php b/config/config.php
index 6c38707d..15a45348 100644
--- a/config/config.php
+++ b/config/config.php
@@ -13,11 +13,13 @@ use Shlinkio\Shlink\Config\ConfigAggregator\EnvVarLoaderProvider;
 
 use function class_exists;
 use function Shlinkio\Shlink\Config\env;
+use function Shlinkio\Shlink\Config\openswooleIsInstalled;
+use function Shlinkio\Shlink\Config\runningInRoadRunner;
 
 use const PHP_SAPI;
 
-$isCli = PHP_SAPI === 'cli';
 $isTestEnv = env('APP_ENV') === 'test';
+$enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner();
 
 return (new ConfigAggregator\ConfigAggregator([
     ! $isTestEnv
@@ -26,7 +28,7 @@ return (new ConfigAggregator\ConfigAggregator([
     Mezzio\ConfigProvider::class,
     Mezzio\Router\ConfigProvider::class,
     Mezzio\Router\FastRouteRouter\ConfigProvider::class,
-    $isCli && class_exists(Swoole\ConfigProvider::class)
+    $enableSwoole && class_exists(Swoole\ConfigProvider::class)
         ? Swoole\ConfigProvider::class
         : new ConfigAggregator\ArrayProvider([]),
     ProblemDetails\ConfigProvider::class,
diff --git a/config/roadrunner/.rr.dev.yml b/config/roadrunner/.rr.dev.yml
new file mode 100644
index 00000000..7adf4520
--- /dev/null
+++ b/config/roadrunner/.rr.dev.yml
@@ -0,0 +1,49 @@
+version: '2.7'
+
+rpc:
+  listen: tcp://127.0.0.1:6001
+
+server:
+  command: 'php ../../bin/roadrunner-worker.php'
+
+http:
+  address: '0.0.0.0:8080'
+  middleware: ['static']
+  static:
+    dir: '../../public'
+    forbid: ['.php', '.htaccess']
+  pool:
+    num_workers: 16
+
+jobs:
+  pool:
+    num_workers: 16
+  timeout: 300
+  consume: ['shlink']
+  pipelines:
+    shlink:
+      driver: memory
+      config:
+        priority: 10
+        prefetch: 10
+
+logs:
+  mode: development
+  channels:
+    http:
+      level: debug
+    server:
+      level: debug
+    metrics:
+      level: debug
+
+reload:
+  interval: 1s
+  patterns: ['.php']
+  services:
+    http:
+      dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor']
+      recursive: true
+    jobs:
+      dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor']
+      recursive: true
diff --git a/config/roadrunner/.rr.yml b/config/roadrunner/.rr.yml
new file mode 100644
index 00000000..d44801ee
--- /dev/null
+++ b/config/roadrunner/.rr.yml
@@ -0,0 +1,36 @@
+version: '2.7'
+
+rpc:
+  listen: tcp://127.0.0.1:6001
+
+server:
+  command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
+
+http:
+  address: '0.0.0.0:${PORT}'
+  middleware: ['static']
+  static:
+    dir: '../../public'
+    forbid: ['.php', '.htaccess']
+  pool:
+    num_workers: ${WEB_WORKER_NUM}
+
+jobs:
+  timeout: 300 # 5 minutes
+  pool:
+    num_workers: ${TASK_WORKER_NUM}
+  consume: ['shlink']
+  pipelines:
+    shlink:
+      driver: memory
+      config:
+        priority: 10
+        prefetch: 10
+
+logs:
+  mode: production
+  channels:
+    http:
+      level: info # Log all http requests, set to info to disable
+    server:
+      level: debug # Everything written to worker stderr is logged
diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile
new file mode 100644
index 00000000..8520b92d
--- /dev/null
+++ b/data/infra/roadrunner.Dockerfile
@@ -0,0 +1,73 @@
+FROM php:8.1.9-alpine3.16
+MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
+
+ENV APCU_VERSION 5.1.21
+ENV PDO_SQLSRV_VERSION 5.10.1
+ENV MS_ODBC_SQL_VERSION 17.5.2.2
+
+RUN apk update
+
+# Install common php extensions
+RUN docker-php-ext-install pdo_mysql
+RUN docker-php-ext-install calendar
+
+RUN apk add --no-cache oniguruma-dev
+RUN docker-php-ext-install mbstring
+
+RUN apk add --no-cache sqlite-libs
+RUN apk add --no-cache sqlite-dev
+RUN docker-php-ext-install pdo_sqlite
+
+RUN apk add --no-cache icu-dev
+RUN docker-php-ext-install intl
+
+RUN apk add --no-cache libzip-dev zlib-dev
+RUN docker-php-ext-install zip
+
+RUN apk add --no-cache libpng-dev
+RUN docker-php-ext-install gd
+
+RUN apk add --no-cache postgresql-dev
+RUN docker-php-ext-install pdo_pgsql
+
+RUN docker-php-ext-install sockets
+RUN docker-php-ext-install bcmath
+
+# Install APCu extension
+ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
+RUN mkdir -p /usr/src/php/ext/apcu \
+  && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
+  && docker-php-ext-configure apcu \
+  && docker-php-ext-install apcu \
+  && rm /tmp/apcu.tar.gz \
+  && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
+  && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
+
+# Install pcov and sqlsrv driver
+RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
+    apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
+    apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
+    pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
+    docker-php-ext-enable pdo_sqlsrv pcov && \
+    apk del .phpize-deps && \
+    rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
+
+# Install composer
+COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
+
+# Make home directory writable by anyone
+RUN chmod 777 /home
+
+VOLUME /home/shlink
+WORKDIR /home/shlink
+
+# Expose roadrunner port
+EXPOSE 8080
+
+CMD \
+    # Install dependencies if the vendor dir does not exist
+    if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \
+    # Download roadrunner binary
+    if [[ ! -f "./bin/rr" ]]; then ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr ; fi && \
+    # This forces the app to be started every second until the exit code is 0
+    until ./bin/rr serve -c config/roadrunner/.rr.dev.yml; do sleep 1 ; done
diff --git a/docker-compose.override.yml.dist b/docker-compose.override.yml.dist
index 990d1b5d..1c5409c6 100644
--- a/docker-compose.override.yml.dist
+++ b/docker-compose.override.yml.dist
@@ -13,6 +13,12 @@ services:
             - /etc/passwd:/etc/passwd:ro
             - /etc/group:/etc/group:ro
 
+    shlink_roadrunner:
+        user: 1000:1000
+        volumes:
+            - /etc/passwd:/etc/passwd:ro
+            - /etc/group:/etc/group:ro
+
     shlink_db_mysql:
         user: 1000:1000
         volumes:
diff --git a/docker-compose.yml b/docker-compose.yml
index 739c0079..8293ab03 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -73,6 +73,30 @@ services:
         extra_hosts:
             - 'host.docker.internal:host-gateway'
 
+    shlink_roadrunner:
+        container_name: shlink_roadrunner
+        build:
+            context: .
+            dockerfile: ./data/infra/roadrunner.Dockerfile
+        ports:
+            - "8800:8080"
+        volumes:
+            - ./:/home/shlink
+            - ./data/infra/php.ini:/usr/local/etc/php/php.ini
+        links:
+            - shlink_db_mysql
+            - shlink_db_postgres
+            - shlink_db_maria
+            - shlink_db_ms
+            - shlink_redis
+            - shlink_mercure
+            - shlink_mercure_proxy
+            - shlink_rabbitmq
+        environment:
+            LC_ALL: C
+        extra_hosts:
+            - 'host.docker.internal:host-gateway'
+
     shlink_db_mysql:
         container_name: shlink_db_mysql
         image: mysql:5.7
@@ -144,8 +168,8 @@ services:
             - "3080:80"
         environment:
             SERVER_NAME: ":80"
-            MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key
-            MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key
+            MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
+            MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
             MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000"
 
     shlink_rabbitmq:
diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php
index 4fba24b6..9dc99351 100644
--- a/docker/config/shlink_in_docker.local.php
+++ b/docker/config/shlink_in_docker.local.php
@@ -6,11 +6,14 @@ namespace Shlinkio\Shlink;
 
 use Shlinkio\Shlink\Common\Logger\LoggerType;
 
+use function Shlinkio\Shlink\Config\runningInRoadRunner;
+
 return [
 
     'logger' => [
         'Shlink' => [
             'type' => LoggerType::STREAM->value,
+            'destination' => runningInRoadRunner() ? 'php://stderr' : 'php://stdout',
         ],
     ],
 
diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh
index f1c4c495..a1cc03be 100644
--- a/docker/docker-entrypoint.sh
+++ b/docker/docker-entrypoint.sh
@@ -31,6 +31,15 @@ if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then
   /usr/sbin/crond &
 fi
 
-# When restarting the container, openswoole might think it is already in execution
-# This forces the app to be started every second until the exit code is 0
-until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
+# RoadRunner config needs these to have been set, so falling back to default values if not set yet
+export PORT="${PORT:-"8765"}"
+export WEB_WORKER_NUM="${WEB_WORKER_NUM:-"16"}"
+export TASK_WORKER_NUM="${TASK_WORKER_NUM:-"16"}"
+
+if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then
+  # When restarting the container, openswoole might think it is already in execution
+  # This forces the app to be started every second until the exit code is 0
+  until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
+elif [ "$SHLINK_RUNTIME" == 'rr' ]; then
+  ./bin/rr serve -c config/roadrunner/.rr.yml
+fi
diff --git a/indocker b/indocker
index 789386ac..03061e2f 100755
--- a/indocker
+++ b/indocker
@@ -1,7 +1,7 @@
 #!/usr/bin/env bash
 
 # Run docker containers if they are not up yet
-if ! [[ $(docker ps | grep shlink_swoole) ]]; then
+if ! [[ $(docker ps | grep shlink) ]]; then
     docker-compose up -d
 fi
 
diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php
index 6fadaa5d..907b3d9c 100644
--- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php
+++ b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php
@@ -5,10 +5,11 @@ declare(strict_types=1);
 namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
 
 use JsonSerializable;
+use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable;
 
-abstract class AbstractVisitEvent implements JsonSerializable
+abstract class AbstractVisitEvent implements JsonSerializable, JsonUnserializable
 {
-    public function __construct(public readonly string $visitId)
+    final public function __construct(public readonly string $visitId)
     {
     }
 
@@ -16,4 +17,9 @@ abstract class AbstractVisitEvent implements JsonSerializable
     {
         return ['visitId' => $this->visitId];
     }
+
+    public static function fromPayload(array $payload): self
+    {
+        return new static($payload['visitId'] ?? '');
+    }
 }
diff --git a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php
index 9786808f..b6ab1a0c 100644
--- a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php
+++ b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php
@@ -5,8 +5,9 @@ declare(strict_types=1);
 namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
 
 use JsonSerializable;
+use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable;
 
-final class ShortUrlCreated implements JsonSerializable
+final class ShortUrlCreated implements JsonSerializable, JsonUnserializable
 {
     public function __construct(public readonly string $shortUrlId)
     {
@@ -18,4 +19,9 @@ final class ShortUrlCreated implements JsonSerializable
             'shortUrlId' => $this->shortUrlId,
         ];
     }
+
+    public static function fromPayload(array $payload): self
+    {
+        return new self($payload['shortUrlId'] ?? '');
+    }
 }
diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php
index 02452a3e..c57d59d6 100644
--- a/module/Core/src/EventDispatcher/Event/UrlVisited.php
+++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php
@@ -6,8 +6,18 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
 
 final class UrlVisited extends AbstractVisitEvent
 {
-    public function __construct(string $visitId, public readonly ?string $originalIpAddress = null)
+    private ?string $originalIpAddress = null;
+
+    public static function withOriginalIpAddress(string $visitId, ?string $originalIpAddress): self
+    {
+        $instance = new self($visitId);
+        $instance->originalIpAddress = $originalIpAddress;
+
+        return $instance;
+    }
+
+    public function originalIpAddress(): ?string
     {
-        parent::__construct($visitId);
+        return $this->originalIpAddress;
     }
 }
diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php
index 197ce9a0..8708fb8a 100644
--- a/module/Core/src/EventDispatcher/LocateVisit.php
+++ b/module/Core/src/EventDispatcher/LocateVisit.php
@@ -41,7 +41,7 @@ class LocateVisit
             return;
         }
 
-        $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit);
+        $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit);
         $this->eventDispatcher->dispatch(new VisitLocated($visitId));
     }
 
diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php
index 3aef46df..585a0f86 100644
--- a/module/Core/src/Visit/VisitsTracker.php
+++ b/module/Core/src/Visit/VisitsTracker.php
@@ -72,6 +72,6 @@ class VisitsTracker implements VisitsTrackerInterface
         $this->em->persist($visit);
         $this->em->flush();
 
-        $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress));
+        $this->eventDispatcher->dispatch(UrlVisited::withOriginalIpAddress($visit->getId(), $visitor->remoteAddress));
     }
 }
diff --git a/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php b/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php
index 8eb98153..73a6ac69 100644
--- a/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php
+++ b/module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php
@@ -11,14 +11,14 @@ use Psr\Http\Server\RequestHandlerInterface;
 
 class DropDefaultDomainFromRequestMiddleware implements MiddlewareInterface
 {
-    public function __construct(private string $defaultDomain)
+    public function __construct(private readonly string $defaultDomain)
     {
     }
 
     public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
     {
         /** @var array $body */
-        $body = $request->getParsedBody();
+        $body = $request->getParsedBody() ?? [];
         $request = $request->withQueryParams($this->sanitizeDomainFromPayload($request->getQueryParams()))
                            ->withParsedBody($this->sanitizeDomainFromPayload($body));
 


Test patch:
diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh
index 3f6e27e6..1cbf948a 100755
--- a/bin/test/run-api-tests.sh
+++ b/bin/test/run-api-tests.sh
@@ -1,25 +1,38 @@
 #!/usr/bin/env sh
+
 export APP_ENV=test
-export DB_DRIVER=postgres
 export TEST_ENV=api
-export GENERATE_COVERAGE=${GENERATE_COVERAGE:-"no"}
+export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}"
+export DB_DRIVER="${DB_DRIVER:-"postgres"}"
+export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}"
 
 # Reset logs
+OUTPUT_LOGS=data/log/api-tests/output.log
 rm -rf data/log/api-tests
 mkdir data/log/api-tests
-touch data/log/api-tests/output.log
+touch $OUTPUT_LOGS
 
 # Try to stop server just in case it hanged in last execution
-vendor/bin/laminas mezzio:swoole:stop
+[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop
+[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f
 
 echo 'Starting server...'
-vendor/bin/laminas mezzio:swoole:start -d
-sleep 2
+[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:start -d
+[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=config/roadrunner/.rr.dev.yml \
+  -o=http.address=0.0.0.0:9999 \
+  -o=logs.encoding=json \
+  -o=logs.channels.http.encoding=json \
+  -o=logs.channels.server.encoding=json \
+  -o=logs.output="${PWD}/${OUTPUT_LOGS}" \
+  -o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \
+  -o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" &
+sleep 2 # Let's give the server a couple of seconds to start
 
 vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $*
 testsExitCode=$?
 
-vendor/bin/laminas mezzio:swoole:stop
+[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop
+[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c config/roadrunner/.rr.dev.yml -o=http.address=0.0.0.0:9999
 
 # Exit this script with the same code as the tests. If tests failed, this script has to fail
 exit $testsExitCode
diff --git a/config/test/bootstrap_api_tests.php b/config/test/bootstrap_api_tests.php
index 52c9d4fb..bc119284 100644
--- a/config/test/bootstrap_api_tests.php
+++ b/config/test/bootstrap_api_tests.php
@@ -10,8 +10,8 @@ use Psr\Container\ContainerInterface;
 use function register_shutdown_function;
 use function sprintf;
 
-use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST;
-use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT;
+use const ShlinkioTest\Shlink\API_TESTS_HOST;
+use const ShlinkioTest\Shlink\API_TESTS_PORT;
 
 /** @var ContainerInterface $container */
 $container = require __DIR__ . '/../container.php';
@@ -24,7 +24,7 @@ $httpClient = $container->get('shlink_test_api_client');
 register_shutdown_function(function () use ($httpClient): void {
     $httpClient->request(
         'GET',
-        sprintf('http://%s:%s/api-tests/stop-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT),
+        sprintf('http://%s:%s/api-tests/stop-coverage', API_TESTS_HOST, API_TESTS_PORT),
     );
 });
 
diff --git a/config/test/constants.php b/config/test/constants.php
index a2c880fc..c767abc9 100644
--- a/config/test/constants.php
+++ b/config/test/constants.php
@@ -4,5 +4,5 @@ declare(strict_types=1);
 
 namespace ShlinkioTest\Shlink;
 
-const SWOOLE_TESTING_HOST = '127.0.0.1';
-const SWOOLE_TESTING_PORT = 9999;
+const API_TESTS_HOST = '127.0.0.1';
+const API_TESTS_PORT = 9999;
diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php
index 8998dd22..9b338d7a 100644
--- a/config/test/test_config.global.php
+++ b/config/test/test_config.global.php
@@ -34,8 +34,8 @@ use function Shlinkio\Shlink\Config\env;
 use function sprintf;
 use function sys_get_temp_dir;
 
-use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST;
-use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT;
+use const ShlinkioTest\Shlink\API_TESTS_HOST;
+use const ShlinkioTest\Shlink\API_TESTS_PORT;
 
 $isApiTest = env('TEST_ENV') === 'api';
 $isCliTest = env('TEST_ENV') === 'cli';
@@ -136,8 +136,8 @@ return [
     'mezzio-swoole' => [
         'enable_coroutine' => false,
         'swoole-http-server' => [
-            'host' => SWOOLE_TESTING_HOST,
-            'port' => SWOOLE_TESTING_PORT,
+            'host' => API_TESTS_HOST,
+            'port' => API_TESTS_PORT,
             'process-name' => 'shlink_test',
             'options' => [
                 'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
@@ -188,7 +188,7 @@ return [
     'dependencies' => [
         'services' => [
             'shlink_test_api_client' => new Client([
-                'base_uri' => sprintf('http://%s:%s/', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT),
+                'base_uri' => sprintf('http://%s:%s/', API_TESTS_HOST, API_TESTS_PORT),
                 'http_errors' => false,
             ]),
         ],
diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php
index 406e8146..09a8086d 100644
--- a/module/Core/test/EventDispatcher/LocateVisitTest.php
+++ b/module/Core/test/EventDispatcher/LocateVisitTest.php
@@ -193,7 +193,7 @@ class LocateVisitTest extends TestCase
     {
         $ipAddr = $originalIpAddress ?? $visit->getRemoteAddr();
         $location = new Location('', '', '', '', 0.0, 0.0, '');
-        $event = new UrlVisited('123', $originalIpAddress);
+        $event = UrlVisited::withOriginalIpAddress('123', $originalIpAddress);
 
         $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
         $flush = $this->em->flush()->will(function (): void {