Drupal on OxPHP

Drupal 11 uses web/index.php as a front controller with clean URLs. Every non-file path rewrites to index.php, which is exactly what OxPHP's framework routing mode does. Aggregated CSS/JS land in web/sites/default/files/ and are served from disk like any other static asset.

Stack at a glance

  • OxPHP image: ghcr.io/oxphp/oxphp:0.13.0-php8.4-alpine3.23 (PHP 8.4)
  • Routing mode: Framework (ENTRY_FILE=index.php; DOCUMENT_ROOT=/var/www/html/web overrides the default …/public)
  • Extensions added: gd, pdo_mysql, zip, mbstring
  • Services: OxPHP + MySQL
  • URL: http://localhost:8097 · internal http://localhost:9098/health
PHP version

Drupal 11 requires PHP 8.3+ and officially supports 8.4. Its Symfony-component stack predates PHP 8.5, so this recipe pins the PHP 8.4 OxPHP image. Drupal uses PDO (pdo_mysql), not mysqli.

Project layout

bash
mkdir -p drupal-oxphp/src docker run --rm -e COMPOSER_ALLOW_SUPERUSER=1 \ -v "$PWD/drupal-oxphp/src":/app -w /app \ composer:2 create-project drupal/recommended-project . \ --ignore-platform-reqs --no-interaction --prefer-dist docker run --rm -e COMPOSER_ALLOW_SUPERUSER=1 \ -v "$PWD/drupal-oxphp/src":/app -w /app \ composer:2 require drush/drush --ignore-platform-reqs --no-interaction
Note

--ignore-platform-reqs is needed because the stock composer:2 image has no gd; the extension is only required at runtime, where the OxPHP image provides it.

Dockerfile and Compose

src/Dockerfile
ARG OXPHP_VERSION=0.13.0 ARG PHP_VERSION=8.4 ARG ALPINE_VERSION=3.23 # ── PHP base: Drupal runtime extensions ─────────────────────── FROM php:${PHP_VERSION}-zts-alpine${ALPINE_VERSION} AS php-base RUN apk add --no-cache \ libpng libjpeg-turbo freetype oniguruma libzip \ && apk add --no-cache --virtual .build-deps \ libpng-dev libjpeg-turbo-dev freetype-dev oniguruma-dev libzip-dev \ && docker-php-ext-configure gd --with-jpeg --with-freetype \ && docker-php-ext-install -j"$(nproc)" gd pdo_mysql zip mbstring \ && apk del .build-deps # ── Composer ────────────────────────────────────────────────── FROM composer:2 AS composer # ── OxPHP artifacts (PHP 8.4 image) ─────────────────────────── FROM ghcr.io/oxphp/oxphp:${OXPHP_VERSION}-php${PHP_VERSION}-alpine${ALPINE_VERSION} AS oxphp # ── dev: OxPHP server + PHP CLI + Composer + drush ──────────── FROM php-base AS dev RUN apk add --no-cache libgcc git COPY --from=composer /usr/bin/composer /usr/local/bin/composer COPY --from=oxphp /usr/local/bin/oxphp /usr/local/bin/oxphp COPY --from=oxphp /usr/local/lib/liboxphp_bridge.so /usr/local/lib/ COPY --from=oxphp /usr/local/lib/php/extensions/ /tmp/oxphp-ext/ RUN cp /tmp/oxphp-ext/*/oxphp_sapi.so "$(php -r 'echo ini_get("extension_dir");')/" \ && rm -rf /tmp/oxphp-ext \ && echo "extension=oxphp_sapi.so" > /usr/local/etc/php/conf.d/oxphp-ext.ini RUN { \ echo "[opcache]"; echo "opcache.enable=1"; echo "opcache.enable_cli=1"; \ echo "opcache.validate_timestamps=1"; echo "opcache.revalidate_freq=0"; \ } > /usr/local/etc/php/conf.d/opcache-dev.ini RUN { \ echo "memory_limit=256M"; echo "upload_max_filesize=64M"; \ echo "post_max_size=64M"; echo "max_execution_time=300"; \ } > /usr/local/etc/php/conf.d/drupal.ini RUN getent passwd www-data >/dev/null \ || adduser -D -H -u 82 -G www-data -s /sbin/nologin www-data RUN mkdir -p /var/www/html/web && chown -R www-data:www-data /var/www/html ENV LD_LIBRARY_PATH=/usr/local/lib WORKDIR /var/www/html EXPOSE 80 9090 CMD ["oxphp"]

Install and first run

drush is the cleanest non-interactive installer:

bash
docker compose up -d --build docker compose exec app vendor/bin/drush site:install standard \ --db-url=mysql://drupal:drupal@db:3306/drupal \ --account-name=admin --account-pass=admin123 \ --site-name="Drupal OxPHP" -y

OxPHP notes

Note
  • Clean URLs work through framework mode. Drupal's RewriteCond !-f + RewriteRule ^ index.php is the same rule framework mode applies: existing files are served, and everything else is dispatched to web/index.php.
  • Aggregated assets are served from disk. Drupal writes combined CSS/JS to web/sites/default/files/css and …/js, and OxPHP serves them as static files.
  • Only the first request is slow. That cold request warms Twig compilation, asset aggregation, and caches (≈15 s); subsequent requests take single-digit milliseconds. The delay is Drupal warming up, not OxPHP overhead.

Verify

bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8097/ # 200 curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8097/user/login # 200 curl -s -o /dev/null -w "%{http_code}\n" http://localhost:9098/health # 200

Prompts for your agent

These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.

prompt
Run my Drupal site on OxPHP locally. Read the `examples/cms/drupal` page through the OxPHP documentation MCP server. Write the Dockerfile and the Compose file for this repository, pinning the PHP version my contrib modules actually allow, and give me the drush sequence that installs it against a fresh database.
prompt
My Drupal site on OxPHP comes back unstyled, or 404s outright. Read the `examples/cms/drupal` page through the OxPHP documentation MCP server. Compare the `DOCUMENT_ROOT` in my Compose file with the web root my `composer.json` scaffolds and with where my aggregated CSS and JS are written, and tell me which one does not line up.

See also

Found a mistake? Report it →