From 013395b49624a9c163f2b19056b3f200766d5207 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Fri, 1 Aug 2025 13:58:43 +0200 Subject: [PATCH 01/14] 4544: POC for using FrankenPHP behind Traefik --- Dockerfile | 14 + composer.json | 1 + composer.lock | 929 +++++++++++++++++++++++++++------------------ docker-compose.yml | 88 +++-- 4 files changed, 645 insertions(+), 387 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f155f994 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM dunglas/frankenphp + +# add additional extensions here: +RUN install-php-extensions \ + pdo_mysql \ + gd \ + intl \ + zip \ + opcache \ + amqp \ + xdebug + +# Install composer +COPY --from=composer/composer:2-bin /composer /usr/bin/composer \ No newline at end of file diff --git a/composer.json b/composer.json index abe21331..d1b273e4 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "ocramius/doctrine-batch-utils": "^2.8", "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpdoc-parser": "^2.0", + "runtime/frankenphp-symfony": "^0.2.0", "symfony/amqp-messenger": "^7.2", "symfony/asset": "^7.2", "symfony/browser-kit": "^7.2", diff --git a/composer.lock b/composer.lock index cbd5d07d..3601b5a3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d347cd87a3a3926f9844ee4768154429", + "content-hash": "8ec4d8e9d3fcccc1400325df8e87a0f3", "packages": [ { "name": "api-platform/core", - "version": "v4.1.17", + "version": "v4.1.20", "source": { "type": "git", "url": "https://github.com/api-platform/core.git", - "reference": "e1191ca86e548d7610251bba2d8498b440cfd631" + "reference": "347c27e98e6a0c1c8d91da2dd069171ebe49da77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/core/zipball/e1191ca86e548d7610251bba2d8498b440cfd631", - "reference": "e1191ca86e548d7610251bba2d8498b440cfd631", + "url": "https://api.github.com/repos/api-platform/core/zipball/347c27e98e6a0c1c8d91da2dd069171ebe49da77", + "reference": "347c27e98e6a0c1c8d91da2dd069171ebe49da77", "shasum": "" }, "require": { @@ -33,6 +33,7 @@ "symfony/serializer": "^6.4 || ^7.0", "symfony/translation-contracts": "^3.3", "symfony/type-info": "^7.2", + "symfony/validator": "^6.4 || ^7.1", "symfony/web-link": "^6.4 || ^7.1", "willdurand/negotiation": "^3.1" }, @@ -218,9 +219,9 @@ ], "support": { "issues": "https://github.com/api-platform/core/issues", - "source": "https://github.com/api-platform/core/tree/v4.1.17" + "source": "https://github.com/api-platform/core/tree/v4.1.20" }, - "time": "2025-06-19T10:14:20+00:00" + "time": "2025-07-25T15:31:58+00:00" }, { "name": "composer/semver", @@ -391,21 +392,21 @@ }, { "name": "doctrine/dbal", - "version": "4.2.4", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "b37d160498ea91a2382a2ebe825c4ea6254fc0ec" + "reference": "ac336c95ea9e13433d56ca81c308b39db0e1a2a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/b37d160498ea91a2382a2ebe825c4ea6254fc0ec", - "reference": "b37d160498ea91a2382a2ebe825c4ea6254fc0ec", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/ac336c95ea9e13433d56ca81c308b39db0e1a2a7", + "reference": "ac336c95ea9e13433d56ca81c308b39db0e1a2a7", "shasum": "" }, "require": { - "doctrine/deprecations": "^0.5.3|^1", - "php": "^8.1", + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", "psr/cache": "^1|^2|^3", "psr/log": "^1|^2|^3" }, @@ -416,7 +417,7 @@ "phpstan/phpstan": "2.1.17", "phpstan/phpstan-phpunit": "2.0.6", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "10.5.46", + "phpunit/phpunit": "11.5.23", "slevomat/coding-standard": "8.16.2", "squizlabs/php_codesniffer": "3.13.1", "symfony/cache": "^6.3.8|^7.0", @@ -477,7 +478,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.2.4" + "source": "https://github.com/doctrine/dbal/tree/4.3.1" }, "funding": [ { @@ -493,7 +494,7 @@ "type": "tidelift" } ], - "time": "2025-06-15T23:15:01+00:00" + "time": "2025-07-22T10:09:51+00:00" }, { "name": "doctrine/deprecations", @@ -1081,16 +1082,16 @@ }, { "name": "doctrine/migrations", - "version": "3.9.1", + "version": "3.9.2", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "0f1e0c960ac29866d648a4f50142a74fe1cb6999" + "reference": "fa94c6f06b1bc6d4759481ec20b8b81d13e861be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/0f1e0c960ac29866d648a4f50142a74fe1cb6999", - "reference": "0f1e0c960ac29866d648a4f50142a74fe1cb6999", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/fa94c6f06b1bc6d4759481ec20b8b81d13e861be", + "reference": "fa94c6f06b1bc6d4759481ec20b8b81d13e861be", "shasum": "" }, "require": { @@ -1108,18 +1109,18 @@ "doctrine/orm": "<2.12 || >=4" }, "require-dev": { - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^13", "doctrine/orm": "^2.13 || ^3", "doctrine/persistence": "^2 || ^3 || ^4", "doctrine/sql-formatter": "^1.0", "ext-pdo_sqlite": "*", "fig/log-test": "^1", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-deprecation-rules": "^1.1", - "phpstan/phpstan-phpunit": "^1.3", - "phpstan/phpstan-strict-rules": "^1.4", - "phpstan/phpstan-symfony": "^1.3", - "phpunit/phpunit": "^10.3", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpstan/phpstan-symfony": "^2", + "phpunit/phpunit": "^10.3 || ^11.0 || ^12.0", "symfony/cache": "^5.4 || ^6.0 || ^7.0", "symfony/process": "^5.4 || ^6.0 || ^7.0", "symfony/yaml": "^5.4 || ^6.0 || ^7.0" @@ -1164,7 +1165,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.1" + "source": "https://github.com/doctrine/migrations/tree/3.9.2" }, "funding": [ { @@ -1180,20 +1181,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T07:19:23+00:00" + "time": "2025-07-29T11:36:14+00:00" }, { "name": "doctrine/orm", - "version": "3.4.3", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "ef607f26c2965fe460c55733cc7c031fb7e1f2fa" + "reference": "6deec3655ba3e8f15280aac11e264225854d2369" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/ef607f26c2965fe460c55733cc7c031fb7e1f2fa", - "reference": "ef607f26c2965fe460c55733cc7c031fb7e1f2fa", + "url": "https://api.github.com/repos/doctrine/orm/zipball/6deec3655ba3e8f15280aac11e264225854d2369", + "reference": "6deec3655ba3e8f15280aac11e264225854d2369", "shasum": "" }, "require": { @@ -1268,9 +1269,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.4.3" + "source": "https://github.com/doctrine/orm/tree/3.5.0" }, - "time": "2025-06-27T12:14:15+00:00" + "time": "2025-07-01T17:40:53+00:00" }, { "name": "doctrine/persistence", @@ -2212,16 +2213,16 @@ }, { "name": "masterminds/html5", - "version": "2.9.0", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + "reference": "fcf91eb64359852f00d921887b219479b4f21251" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", "shasum": "" }, "require": { @@ -2273,9 +2274,9 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" }, - "time": "2024-03-31T07:05:07+00:00" + "time": "2025-07-25T09:04:22+00:00" }, { "name": "monolog/monolog", @@ -2761,16 +2762,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.1.0", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8", "shasum": "" }, "require": { @@ -2802,9 +2803,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.2.0" }, - "time": "2025-02-19T13:28:12+00:00" + "time": "2025-07-13T07:04:09+00:00" }, { "name": "psr/cache", @@ -3409,18 +3410,70 @@ }, "time": "2024-11-20T21:13:56+00:00" }, + { + "name": "runtime/frankenphp-symfony", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/php-runtime/frankenphp-symfony.git", + "reference": "56822c3631d9522a3136a4c33082d006bdfe4bad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-runtime/frankenphp-symfony/zipball/56822c3631d9522a3136a4c33082d006bdfe4bad", + "reference": "56822c3631d9522a3136a4c33082d006bdfe4bad", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", + "symfony/runtime": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Runtime\\FrankenPhpSymfony\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.dev" + } + ], + "description": "FrankenPHP runtime for Symfony", + "support": { + "issues": "https://github.com/php-runtime/frankenphp-symfony/issues", + "source": "https://github.com/php-runtime/frankenphp-symfony/tree/0.2.0" + }, + "funding": [ + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2023-12-12T12:06:11+00:00" + }, { "name": "symfony/amqp-messenger", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/amqp-messenger.git", - "reference": "635c859743bb8166d1b294e7c63a142a5a0ffc16" + "reference": "0ed5f72c1d9bbfcfc751b3832939a00a3246fe98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/amqp-messenger/zipball/635c859743bb8166d1b294e7c63a142a5a0ffc16", - "reference": "635c859743bb8166d1b294e7c63a142a5a0ffc16", + "url": "https://api.github.com/repos/symfony/amqp-messenger/zipball/0ed5f72c1d9bbfcfc751b3832939a00a3246fe98", + "reference": "0ed5f72c1d9bbfcfc751b3832939a00a3246fe98", "shasum": "" }, "require": { @@ -3460,7 +3513,7 @@ "description": "Symfony AMQP extension Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/amqp-messenger/tree/v7.3.0" + "source": "https://github.com/symfony/amqp-messenger/tree/v7.3.2" }, "funding": [ { @@ -3471,12 +3524,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-25T10:45:43+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/asset", @@ -3549,16 +3606,16 @@ }, { "name": "symfony/browser-kit", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "5384291845e74fd7d54f3d925c4a86ce12336593" + "reference": "f0b889b73a845cddef1d25fe207b37fd04cb5419" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/5384291845e74fd7d54f3d925c4a86ce12336593", - "reference": "5384291845e74fd7d54f3d925c4a86ce12336593", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f0b889b73a845cddef1d25fe207b37fd04cb5419", + "reference": "f0b889b73a845cddef1d25fe207b37fd04cb5419", "shasum": "" }, "require": { @@ -3597,7 +3654,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.3.0" + "source": "https://github.com/symfony/browser-kit/tree/v7.3.2" }, "funding": [ { @@ -3608,25 +3665,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-05T10:15:41+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/cache", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "a7c6caa9d6113cebfb3020b427bcb021ebfdfc9e" + "reference": "6621a2bee5373e3e972b2ae5dbedd5ac899d8cb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/a7c6caa9d6113cebfb3020b427bcb021ebfdfc9e", - "reference": "a7c6caa9d6113cebfb3020b427bcb021ebfdfc9e", + "url": "https://api.github.com/repos/symfony/cache/zipball/6621a2bee5373e3e972b2ae5dbedd5ac899d8cb6", + "reference": "6621a2bee5373e3e972b2ae5dbedd5ac899d8cb6", "shasum": "" }, "require": { @@ -3695,7 +3756,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.3.1" + "source": "https://github.com/symfony/cache/tree/v7.3.2" }, "funding": [ { @@ -3706,12 +3767,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-30T17:13:41+00:00" }, { "name": "symfony/cache-contracts", @@ -3865,16 +3930,16 @@ }, { "name": "symfony/config", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "ba62ae565f1327c2f6366726312ed828c85853bc" + "reference": "faef36e271bbeb74a9d733be4b56419b157762e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/ba62ae565f1327c2f6366726312ed828c85853bc", - "reference": "ba62ae565f1327c2f6366726312ed828c85853bc", + "url": "https://api.github.com/repos/symfony/config/zipball/faef36e271bbeb74a9d733be4b56419b157762e2", + "reference": "faef36e271bbeb74a9d733be4b56419b157762e2", "shasum": "" }, "require": { @@ -3920,7 +3985,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.3.0" + "source": "https://github.com/symfony/config/tree/v7.3.2" }, "funding": [ { @@ -3931,25 +3996,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-15T09:04:05+00:00" + "time": "2025-07-26T13:55:06+00:00" }, { "name": "symfony/console", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101" + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101", - "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101", + "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", "shasum": "" }, "require": { @@ -4014,7 +4083,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.1" + "source": "https://github.com/symfony/console/tree/v7.3.2" }, "funding": [ { @@ -4025,25 +4094,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-30T17:13:41+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "8656c4848b48784c4bb8c4ae50d2b43f832cead8" + "reference": "6cd2a1a77e8a0676a26e8bcddf10acfe7b0ba352" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/8656c4848b48784c4bb8c4ae50d2b43f832cead8", - "reference": "8656c4848b48784c4bb8c4ae50d2b43f832cead8", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/6cd2a1a77e8a0676a26e8bcddf10acfe7b0ba352", + "reference": "6cd2a1a77e8a0676a26e8bcddf10acfe7b0ba352", "shasum": "" }, "require": { @@ -4094,7 +4167,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.3.1" + "source": "https://github.com/symfony/dependency-injection/tree/v7.3.2" }, "funding": [ { @@ -4105,12 +4178,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T04:04:43+00:00" + "time": "2025-07-30T17:31:46+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4181,16 +4258,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "6c0acb248c46452ae2c15752dc71e72f3335403f" + "reference": "a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/6c0acb248c46452ae2c15752dc71e72f3335403f", - "reference": "6c0acb248c46452ae2c15752dc71e72f3335403f", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca", + "reference": "a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca", "shasum": "" }, "require": { @@ -4239,7 +4316,7 @@ "symfony/security-core": "^6.4|^7.0", "symfony/stopwatch": "^6.4|^7.0", "symfony/translation": "^6.4|^7.0", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.1.8", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0" @@ -4270,7 +4347,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.1" + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.2" }, "funding": [ { @@ -4281,25 +4358,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T13:02:59+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/doctrine-messenger", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "099d9cd03f889c31c90d406fed07f25dc3732487" + "reference": "31ef09fa3185c8ef9a331170b7a9dd891047f5cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/099d9cd03f889c31c90d406fed07f25dc3732487", - "reference": "099d9cd03f889c31c90d406fed07f25dc3732487", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/31ef09fa3185c8ef9a331170b7a9dd891047f5cb", + "reference": "31ef09fa3185c8ef9a331170b7a9dd891047f5cb", "shasum": "" }, "require": { @@ -4342,7 +4423,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v7.3.0" + "source": "https://github.com/symfony/doctrine-messenger/tree/v7.3.2" }, "funding": [ { @@ -4353,12 +4434,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-26T11:30:13+00:00" + "time": "2025-07-30T17:13:41+00:00" }, { "name": "symfony/dom-crawler", @@ -4429,16 +4514,16 @@ }, { "name": "symfony/dotenv", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "28347a897771d0c28e99b75166dd2689099f3045" + "reference": "2192790a11f9e22cbcf9dc705a3ff22a5503923a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/28347a897771d0c28e99b75166dd2689099f3045", - "reference": "28347a897771d0c28e99b75166dd2689099f3045", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/2192790a11f9e22cbcf9dc705a3ff22a5503923a", + "reference": "2192790a11f9e22cbcf9dc705a3ff22a5503923a", "shasum": "" }, "require": { @@ -4483,7 +4568,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.3.0" + "source": "https://github.com/symfony/dotenv/tree/v7.3.2" }, "funding": [ { @@ -4494,25 +4579,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-27T11:18:42+00:00" + "time": "2025-07-10T08:29:33+00:00" }, { "name": "symfony/error-handler", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235" + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235", - "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", "shasum": "" }, "require": { @@ -4560,7 +4649,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.1" + "source": "https://github.com/symfony/error-handler/tree/v7.3.2" }, "funding": [ { @@ -4571,12 +4660,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:48:40+00:00" + "time": "2025-07-07T08:17:57+00:00" }, { "name": "symfony/event-dispatcher", @@ -4736,16 +4829,16 @@ }, { "name": "symfony/expression-language", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "26f4884a455e755e630a5fc372df124a3578da2e" + "reference": "32d2d19c62e58767e6552166c32fb259975d2b23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/26f4884a455e755e630a5fc372df124a3578da2e", - "reference": "26f4884a455e755e630a5fc372df124a3578da2e", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/32d2d19c62e58767e6552166c32fb259975d2b23", + "reference": "32d2d19c62e58767e6552166c32fb259975d2b23", "shasum": "" }, "require": { @@ -4780,7 +4873,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v7.3.0" + "source": "https://github.com/symfony/expression-language/tree/v7.3.2" }, "funding": [ { @@ -4791,25 +4884,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-15T11:52:45+00:00" + "time": "2025-07-10T08:29:33+00:00" }, { "name": "symfony/filesystem", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" + "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd", "shasum": "" }, "require": { @@ -4846,7 +4943,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.3.0" + "source": "https://github.com/symfony/filesystem/tree/v7.3.2" }, "funding": [ { @@ -4857,25 +4954,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-25T15:15:23+00:00" + "time": "2025-07-07T08:17:47+00:00" }, { "name": "symfony/finder", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", + "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", "shasum": "" }, "require": { @@ -4910,7 +5011,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.0" + "source": "https://github.com/symfony/finder/tree/v7.3.2" }, "funding": [ { @@ -4921,25 +5022,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-30T19:00:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/flex", - "version": "v2.7.1", + "version": "v2.8.1", "source": { "type": "git", "url": "https://github.com/symfony/flex.git", - "reference": "4ae50d368415a06820739e54d38a4a29d6df9155" + "reference": "423c36e369361003dc31ef11c5f15fb589e52c01" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/flex/zipball/4ae50d368415a06820739e54d38a4a29d6df9155", - "reference": "4ae50d368415a06820739e54d38a4a29d6df9155", + "url": "https://api.github.com/repos/symfony/flex/zipball/423c36e369361003dc31ef11c5f15fb589e52c01", + "reference": "423c36e369361003dc31ef11c5f15fb589e52c01", "shasum": "" }, "require": { @@ -4978,7 +5083,7 @@ "description": "Composer plugin for Symfony", "support": { "issues": "https://github.com/symfony/flex/issues", - "source": "https://github.com/symfony/flex/tree/v2.7.1" + "source": "https://github.com/symfony/flex/tree/v2.8.1" }, "funding": [ { @@ -4994,20 +5099,20 @@ "type": "tidelift" } ], - "time": "2025-05-28T14:22:54+00:00" + "time": "2025-07-05T07:45:19+00:00" }, { "name": "symfony/form", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "e06b02dd21b33b0cd7bb942c7e446ef7b22a2a5a" + "reference": "e83e898d1589f3ec647824bd4416defe3d6e3875" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/e06b02dd21b33b0cd7bb942c7e446ef7b22a2a5a", - "reference": "e06b02dd21b33b0cd7bb942c7e446ef7b22a2a5a", + "url": "https://api.github.com/repos/symfony/form/zipball/e83e898d1589f3ec647824bd4416defe3d6e3875", + "reference": "e83e898d1589f3ec647824bd4416defe3d6e3875", "shasum": "" }, "require": { @@ -5075,7 +5180,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v7.3.1" + "source": "https://github.com/symfony/form/tree/v7.3.2" }, "funding": [ { @@ -5086,25 +5191,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:48:40+00:00" + "time": "2025-07-24T12:10:26+00:00" }, { "name": "symfony/framework-bundle", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "91905f22f26aa350a33b3b9690bdf94976b0d0ab" + "reference": "06c0f678129f99bda8b5cf8873b3d8ef5a0029e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/91905f22f26aa350a33b3b9690bdf94976b0d0ab", - "reference": "91905f22f26aa350a33b3b9690bdf94976b0d0ab", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/06c0f678129f99bda8b5cf8873b3d8ef5a0029e7", + "reference": "06c0f678129f99bda8b5cf8873b3d8ef5a0029e7", "shasum": "" }, "require": { @@ -5194,7 +5303,7 @@ "symfony/string": "^6.4|^7.0", "symfony/translation": "^7.3", "symfony/twig-bundle": "^6.4|^7.0", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.1.8", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/web-link": "^6.4|^7.0", @@ -5229,7 +5338,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.3.1" + "source": "https://github.com/symfony/framework-bundle/tree/v7.3.2" }, "funding": [ { @@ -5240,25 +5349,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-30T17:13:41+00:00" }, { "name": "symfony/http-client", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64" + "reference": "1c064a0c67749923483216b081066642751cc2c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/4403d87a2c16f33345dca93407a8714ee8c05a64", - "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64", + "url": "https://api.github.com/repos/symfony/http-client/zipball/1c064a0c67749923483216b081066642751cc2c7", + "reference": "1c064a0c67749923483216b081066642751cc2c7", "shasum": "" }, "require": { @@ -5324,7 +5437,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.1" + "source": "https://github.com/symfony/http-client/tree/v7.3.2" }, "funding": [ { @@ -5335,12 +5448,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-28T07:58:39+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/http-client-contracts", @@ -5422,16 +5539,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "23dd60256610c86a3414575b70c596e5deff6ed9" + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9", - "reference": "23dd60256610c86a3414575b70c596e5deff6ed9", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", "shasum": "" }, "require": { @@ -5481,7 +5598,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.1" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" }, "funding": [ { @@ -5492,25 +5609,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-23T15:07:14+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831" + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831", - "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c", "shasum": "" }, "require": { @@ -5595,7 +5716,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.1" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.2" }, "funding": [ { @@ -5606,25 +5727,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-28T08:24:55+00:00" + "time": "2025-07-31T10:45:04+00:00" }, { "name": "symfony/intl", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "bd50940329ac1cfc4af0491cc4468f477d967e45" + "reference": "d1197fb6661b05f6178ddb2dc9c6d576f6f67ec8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/bd50940329ac1cfc4af0491cc4468f477d967e45", - "reference": "bd50940329ac1cfc4af0491cc4468f477d967e45", + "url": "https://api.github.com/repos/symfony/intl/zipball/d1197fb6661b05f6178ddb2dc9c6d576f6f67ec8", + "reference": "d1197fb6661b05f6178ddb2dc9c6d576f6f67ec8", "shasum": "" }, "require": { @@ -5681,7 +5806,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v7.3.1" + "source": "https://github.com/symfony/intl/tree/v7.3.2" }, "funding": [ { @@ -5692,25 +5817,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-06T16:10:07+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/messenger", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "716c89b86ce58c4946d436d862694971c999d1aa" + "reference": "f990f0d09deaa45955593be6aafbafe73b0682b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/716c89b86ce58c4946d436d862694971c999d1aa", - "reference": "716c89b86ce58c4946d436d862694971c999d1aa", + "url": "https://api.github.com/repos/symfony/messenger/zipball/f990f0d09deaa45955593be6aafbafe73b0682b9", + "reference": "f990f0d09deaa45955593be6aafbafe73b0682b9", "shasum": "" }, "require": { @@ -5770,7 +5899,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v7.3.1" + "source": "https://github.com/symfony/messenger/tree/v7.3.2" }, "funding": [ { @@ -5781,25 +5910,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/mime", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", "shasum": "" }, "require": { @@ -5854,7 +5987,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.0" + "source": "https://github.com/symfony/mime/tree/v7.3.2" }, "funding": [ { @@ -5865,12 +5998,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-19T08:51:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/monolog-bridge", @@ -6033,16 +6170,16 @@ }, { "name": "symfony/options-resolver", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca" + "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/afb9a8038025e5dbc657378bfab9198d75f10fca", - "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/119bcf13e67dbd188e5dbc74228b1686f66acd37", + "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37", "shasum": "" }, "require": { @@ -6080,7 +6217,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.3.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.3.2" }, "funding": [ { @@ -6091,12 +6228,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-04T13:12:05+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/password-hasher", @@ -6810,16 +6951,16 @@ }, { "name": "symfony/property-access", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "518d15c8cca726ebe665dcd7154074584cf862e8" + "reference": "317916e49b2577a1908f321796f2b67984e61eab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/518d15c8cca726ebe665dcd7154074584cf862e8", - "reference": "518d15c8cca726ebe665dcd7154074584cf862e8", + "url": "https://api.github.com/repos/symfony/property-access/zipball/317916e49b2577a1908f321796f2b67984e61eab", + "reference": "317916e49b2577a1908f321796f2b67984e61eab", "shasum": "" }, "require": { @@ -6866,7 +7007,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.3.1" + "source": "https://github.com/symfony/property-access/tree/v7.3.2" }, "funding": [ { @@ -6877,12 +7018,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T04:04:43+00:00" + "time": "2025-07-15T17:58:03+00:00" }, { "name": "symfony/property-info", @@ -6972,16 +7117,16 @@ }, { "name": "symfony/routing", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "8e213820c5fea844ecea29203d2a308019007c15" + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", - "reference": "8e213820c5fea844ecea29203d2a308019007c15", + "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", "shasum": "" }, "require": { @@ -7033,7 +7178,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.0" + "source": "https://github.com/symfony/routing/tree/v7.3.2" }, "funding": [ { @@ -7044,12 +7189,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-24T20:43:28+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/runtime", @@ -7132,16 +7281,16 @@ }, { "name": "symfony/security-bundle", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "428a281fd66c8358adc2259c8578e6d81fbb7079" + "reference": "d8278a973b305c0b79b162f265d8ce1e96703236" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/428a281fd66c8358adc2259c8578e6d81fbb7079", - "reference": "428a281fd66c8358adc2259c8578e6d81fbb7079", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/d8278a973b305c0b79b162f265d8ce1e96703236", + "reference": "d8278a973b305c0b79b162f265d8ce1e96703236", "shasum": "" }, "require": { @@ -7218,7 +7367,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v7.3.1" + "source": "https://github.com/symfony/security-bundle/tree/v7.3.2" }, "funding": [ { @@ -7229,25 +7378,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T04:04:43+00:00" + "time": "2025-07-22T08:15:39+00:00" }, { "name": "symfony/security-core", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "fafab1003a31e51506e1a0a83e81c072211d81ba" + "reference": "d8e1bb0de26266e2e4525beda0aed7f774e9c80d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/fafab1003a31e51506e1a0a83e81c072211d81ba", - "reference": "fafab1003a31e51506e1a0a83e81c072211d81ba", + "url": "https://api.github.com/repos/symfony/security-core/zipball/d8e1bb0de26266e2e4525beda0aed7f774e9c80d", + "reference": "d8e1bb0de26266e2e4525beda0aed7f774e9c80d", "shasum": "" }, "require": { @@ -7305,7 +7458,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v7.3.1" + "source": "https://github.com/symfony/security-core/tree/v7.3.2" }, "funding": [ { @@ -7316,12 +7469,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-23T07:28:50+00:00" + "time": "2025-07-23T09:11:24+00:00" }, { "name": "symfony/security-csrf", @@ -7395,16 +7552,16 @@ }, { "name": "symfony/security-http", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "b7182ed0fd2359297f78ff6d407265168255ea84" + "reference": "ca8d92035a5c8d31012458589bdaef30ef3c54d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/b7182ed0fd2359297f78ff6d407265168255ea84", - "reference": "b7182ed0fd2359297f78ff6d407265168255ea84", + "url": "https://api.github.com/repos/symfony/security-http/zipball/ca8d92035a5c8d31012458589bdaef30ef3c54d6", + "reference": "ca8d92035a5c8d31012458589bdaef30ef3c54d6", "shasum": "" }, "require": { @@ -7463,7 +7620,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v7.3.1" + "source": "https://github.com/symfony/security-http/tree/v7.3.2" }, "funding": [ { @@ -7474,25 +7631,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T04:04:43+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/serializer", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "feaf837cedbbc8287986602223175d3fd639922d" + "reference": "0ed011583fd24899fa003abf77c45d4a901714da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/feaf837cedbbc8287986602223175d3fd639922d", - "reference": "feaf837cedbbc8287986602223175d3fd639922d", + "url": "https://api.github.com/repos/symfony/serializer/zipball/0ed011583fd24899fa003abf77c45d4a901714da", + "reference": "0ed011583fd24899fa003abf77c45d4a901714da", "shasum": "" }, "require": { @@ -7528,7 +7689,7 @@ "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.1.8", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", @@ -7561,7 +7722,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.3.1" + "source": "https://github.com/symfony/serializer/tree/v7.3.2" }, "funding": [ { @@ -7572,12 +7733,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-26T13:07:17+00:00" }, { "name": "symfony/service-contracts", @@ -7726,16 +7891,16 @@ }, { "name": "symfony/string", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", + "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", "shasum": "" }, "require": { @@ -7793,7 +7958,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.0" + "source": "https://github.com/symfony/string/tree/v7.3.2" }, "funding": [ { @@ -7804,25 +7969,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-20T20:19:01+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/translation", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063" + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063", + "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90", "shasum": "" }, "require": { @@ -7889,7 +8058,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.1" + "source": "https://github.com/symfony/translation/tree/v7.3.2" }, "funding": [ { @@ -7900,12 +8069,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-30T17:31:46+00:00" }, { "name": "symfony/translation-contracts", @@ -7987,16 +8160,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "082eb15d8a4f9afee0acc4709fbe3aaf26d48891" + "reference": "81d1c69769cf913240afdd4c9673304ddca964b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/082eb15d8a4f9afee0acc4709fbe3aaf26d48891", - "reference": "082eb15d8a4f9afee0acc4709fbe3aaf26d48891", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/81d1c69769cf913240afdd4c9673304ddca964b0", + "reference": "81d1c69769cf913240afdd4c9673304ddca964b0", "shasum": "" }, "require": { @@ -8078,7 +8251,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v7.3.0" + "source": "https://github.com/symfony/twig-bridge/tree/v7.3.2" }, "funding": [ { @@ -8089,25 +8262,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-19T13:28:56+00:00" + "time": "2025-07-26T16:47:03+00:00" }, { "name": "symfony/twig-bundle", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "bc23c11d9716fc2261ee26a32e654b0e8b1b1896" + "reference": "5d85220df4d8d79e6a9ca57eea6f70004de39657" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/bc23c11d9716fc2261ee26a32e654b0e8b1b1896", - "reference": "bc23c11d9716fc2261ee26a32e654b0e8b1b1896", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/5d85220df4d8d79e6a9ca57eea6f70004de39657", + "reference": "5d85220df4d8d79e6a9ca57eea6f70004de39657", "shasum": "" }, "require": { @@ -8162,7 +8339,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v7.3.1" + "source": "https://github.com/symfony/twig-bundle/tree/v7.3.2" }, "funding": [ { @@ -8173,25 +8350,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T04:04:43+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/type-info", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "5fa6e25e4195e73ce9e457b521ac5e61ec271150" + "reference": "b72d44c7d6638480fce101b7c4cd3abea4c2efba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/5fa6e25e4195e73ce9e457b521ac5e61ec271150", - "reference": "5fa6e25e4195e73ce9e457b521ac5e61ec271150", + "url": "https://api.github.com/repos/symfony/type-info/zipball/b72d44c7d6638480fce101b7c4cd3abea4c2efba", + "reference": "b72d44c7d6638480fce101b7c4cd3abea4c2efba", "shasum": "" }, "require": { @@ -8241,7 +8422,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.3.1" + "source": "https://github.com/symfony/type-info/tree/v7.3.2" }, "funding": [ { @@ -8252,12 +8433,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-10T05:39:45+00:00" }, { "name": "symfony/uid", @@ -8335,16 +8520,16 @@ }, { "name": "symfony/ux-twig-component", - "version": "v2.27.0", + "version": "v2.28.2", "source": { "type": "git", "url": "https://github.com/symfony/ux-twig-component.git", - "reference": "0879cd53812b79e8b6a20e104b6e785a2ac2c013" + "reference": "6a6562a470bf6fdf949755c6336ec60f759548f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/0879cd53812b79e8b6a20e104b6e785a2ac2c013", - "reference": "0879cd53812b79e8b6a20e104b6e785a2ac2c013", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/6a6562a470bf6fdf949755c6336ec60f759548f2", + "reference": "6a6562a470bf6fdf949755c6336ec60f759548f2", "shasum": "" }, "require": { @@ -8398,7 +8583,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-twig-component/tree/v2.27.0" + "source": "https://github.com/symfony/ux-twig-component/tree/v2.28.2" }, "funding": [ { @@ -8409,25 +8594,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-21T16:41:28+00:00" + "time": "2025-07-29T15:18:27+00:00" }, { "name": "symfony/validator", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "e2f2497c869fc57446f735fbf00cff4de32ae8c3" + "reference": "e5cc60fd44aab8e1d662fc0d954da322c2e08b43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/e2f2497c869fc57446f735fbf00cff4de32ae8c3", - "reference": "e2f2497c869fc57446f735fbf00cff4de32ae8c3", + "url": "https://api.github.com/repos/symfony/validator/zipball/e5cc60fd44aab8e1d662fc0d954da322c2e08b43", + "reference": "e5cc60fd44aab8e1d662fc0d954da322c2e08b43", "shasum": "" }, "require": { @@ -8466,7 +8655,7 @@ "symfony/property-info": "^6.4|^7.0", "symfony/string": "^6.4|^7.0", "symfony/translation": "^6.4.3|^7.0.3", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.1.8", "symfony/yaml": "^6.4|^7.0" }, "type": "library", @@ -8496,7 +8685,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.3.1" + "source": "https://github.com/symfony/validator/tree/v7.3.2" }, "funding": [ { @@ -8507,25 +8696,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T13:22:23+00:00" + "time": "2025-07-29T20:02:46+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42" + "reference": "53205bea27450dc5c65377518b3275e126d45e75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", - "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", + "reference": "53205bea27450dc5c65377518b3275e126d45e75", "shasum": "" }, "require": { @@ -8537,7 +8730,6 @@ "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", "symfony/console": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", @@ -8580,7 +8772,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.1" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" }, "funding": [ { @@ -8591,25 +8783,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-29T20:02:46+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c" + "reference": "05b3e90654c097817325d6abd284f7938b05f467" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/c9a1168891b5aaadfd6332ef44393330b3498c4c", - "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/05b3e90654c097817325d6abd284f7938b05f467", + "reference": "05b3e90654c097817325d6abd284f7938b05f467", "shasum": "" }, "require": { @@ -8657,7 +8853,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.3.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.3.2" }, "funding": [ { @@ -8668,12 +8864,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-15T09:04:05+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/web-link", @@ -8832,16 +9032,16 @@ }, { "name": "symfony/yaml", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb" + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb", - "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", "shasum": "" }, "require": { @@ -8884,7 +9084,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.1" + "source": "https://github.com/symfony/yaml/tree/v7.3.2" }, "funding": [ { @@ -8895,12 +9095,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-03T06:57:57+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "twig/extra-bundle", @@ -10152,20 +10356,20 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.76.0", + "version": "v3.85.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "0e3c484cef0ae9314b0f85986a36296087432c40" + "reference": "2fb6d7f6c3398dca5786a1635b27405d73a417ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/0e3c484cef0ae9314b0f85986a36296087432c40", - "reference": "0e3c484cef0ae9314b0f85986a36296087432c40", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2fb6d7f6c3398dca5786a1635b27405d73a417ba", + "reference": "2fb6d7f6c3398dca5786a1635b27405d73a417ba", "shasum": "" }, "require": { - "clue/ndjson-react": "^1.0", + "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.5", "ext-filter": "*", @@ -10175,12 +10379,12 @@ "fidry/cpu-core-counter": "^1.2", "php": "^7.4 || ^8.0", "react/child-process": "^0.6.6", - "react/event-loop": "^1.0", - "react/promise": "^2.11 || ^3.0", - "react/socket": "^1.0", - "react/stream": "^1.0", + "react/event-loop": "^1.5", + "react/promise": "^3.2", + "react/socket": "^1.16", + "react/stream": "^1.4", "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", - "symfony/console": "^5.4.45 || ^6.4.13 || ^7.0", + "symfony/console": "^5.4.47 || ^6.4.13 || ^7.0", "symfony/event-dispatcher": "^5.4.45 || ^6.4.13 || ^7.0", "symfony/filesystem": "^5.4.45 || ^6.4.13 || ^7.0", "symfony/finder": "^5.4.45 || ^6.4.17 || ^7.0", @@ -10245,7 +10449,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.76.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.85.1" }, "funding": [ { @@ -10253,7 +10457,7 @@ "type": "github" } ], - "time": "2025-06-30T14:15:06+00:00" + "time": "2025-07-29T22:22:50+00:00" }, { "name": "hautelook/alice-bundle", @@ -10534,16 +10738,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.1", + "version": "1.13.3", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", "shasum": "" }, "require": { @@ -10582,7 +10786,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" }, "funding": [ { @@ -10590,7 +10794,7 @@ "type": "tidelift" } ], - "time": "2025-04-29T12:36:36+00:00" + "time": "2025-07-05T12:25:42+00:00" }, { "name": "nelmio/alice", @@ -10690,16 +10894,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.5.0", + "version": "v5.6.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", "shasum": "" }, "require": { @@ -10742,9 +10946,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" }, - "time": "2025-05-31T08:24:38+00:00" + "time": "2025-07-27T20:03:57+00:00" }, { "name": "phar-io/manifest", @@ -10914,16 +11118,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.17", + "version": "2.1.21", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053" + "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/89b5ef665716fa2a52ecd2633f21007a6a349053", - "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1ccf445757458c06a04eb3f803603cb118fe5fa6", + "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6", "shasum": "" }, "require": { @@ -10968,20 +11172,20 @@ "type": "github" } ], - "time": "2025-05-21T20:55:28+00:00" + "time": "2025-07-28T19:35:08+00:00" }, { "name": "phpstan/phpstan-doctrine", - "version": "2.0.3", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "4497663eb17b9d29211830df5aceaa3a4d256a35" + "reference": "6271e66ce37545bd2edcddbe6bcbdd3b665ab7b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/4497663eb17b9d29211830df5aceaa3a4d256a35", - "reference": "4497663eb17b9d29211830df5aceaa3a4d256a35", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/6271e66ce37545bd2edcddbe6bcbdd3b665ab7b8", + "reference": "6271e66ce37545bd2edcddbe6bcbdd3b665ab7b8", "shasum": "" }, "require": { @@ -11038,27 +11242,27 @@ "description": "Doctrine extensions for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.3" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.4" }, - "time": "2025-05-05T15:28:52+00:00" + "time": "2025-07-17T11:57:55+00:00" }, { "name": "phpstan/phpstan-phpunit", - "version": "2.0.6", + "version": "2.0.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "6b92469f8a7995e626da3aa487099617b8dfa260" + "reference": "9a9b161baee88a5f5c58d816943cff354ff233dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6b92469f8a7995e626da3aa487099617b8dfa260", - "reference": "6b92469f8a7995e626da3aa487099617b8dfa260", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/9a9b161baee88a5f5c58d816943cff354ff233dc", + "reference": "9a9b161baee88a5f5c58d816943cff354ff233dc", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0.4" + "phpstan/phpstan": "^2.1.18" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -11091,22 +11295,22 @@ "description": "PHPUnit extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.6" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.7" }, - "time": "2025-03-26T12:47:06+00:00" + "time": "2025-07-13T11:31:46+00:00" }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.6", + "version": "2.0.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "5005288e07583546ea00b52de4a9ac412eb869d7" + "reference": "392f7ab8f52a0a776977be4e62535358c28e1b15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/5005288e07583546ea00b52de4a9ac412eb869d7", - "reference": "5005288e07583546ea00b52de4a9ac412eb869d7", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/392f7ab8f52a0a776977be4e62535358c28e1b15", + "reference": "392f7ab8f52a0a776977be4e62535358c28e1b15", "shasum": "" }, "require": { @@ -11162,9 +11366,9 @@ "description": "Symfony Framework extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.6" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.7" }, - "time": "2025-05-14T07:00:05+00:00" + "time": "2025-07-22T09:40:57+00:00" }, { "name": "phpunit/php-code-coverage", @@ -11503,16 +11707,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.25", + "version": "11.5.28", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "864ab32b3ff52058f917c5b19b3cef821e4a4f1b" + "reference": "93f30aa3889e785ac63493d4976df0ae9fdecb60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/864ab32b3ff52058f917c5b19b3cef821e4a4f1b", - "reference": "864ab32b3ff52058f917c5b19b3cef821e4a4f1b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/93f30aa3889e785ac63493d4976df0ae9fdecb60", + "reference": "93f30aa3889e785ac63493d4976df0ae9fdecb60", "shasum": "" }, "require": { @@ -11522,7 +11726,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.1", + "myclabs/deep-copy": "^1.13.3", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", @@ -11584,7 +11788,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.25" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.28" }, "funding": [ { @@ -11608,7 +11812,7 @@ "type": "tidelift" } ], - "time": "2025-06-27T04:36:07+00:00" + "time": "2025-07-31T07:10:28+00:00" }, { "name": "react/cache", @@ -12138,21 +12342,21 @@ }, { "name": "rector/rector", - "version": "2.1.0", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "d513dea45a94394b660e15c155d1fa27826f8e30" + "reference": "40a71441dd73fa150a66102f5ca1364c44fc8fff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/d513dea45a94394b660e15c155d1fa27826f8e30", - "reference": "d513dea45a94394b660e15c155d1fa27826f8e30", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/40a71441dd73fa150a66102f5ca1364c44fc8fff", + "reference": "40a71441dd73fa150a66102f5ca1364c44fc8fff", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.17" + "phpstan/phpstan": "^2.1.18" }, "conflict": { "rector/rector-doctrine": "*", @@ -12186,7 +12390,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.1.0" + "source": "https://github.com/rectorphp/rector/tree/2.1.2" }, "funding": [ { @@ -12194,7 +12398,7 @@ "type": "github" } ], - "time": "2025-06-24T20:26:57+00:00" + "time": "2025-07-17T19:30:06+00:00" }, { "name": "sebastian/cli-parser", @@ -13324,16 +13528,16 @@ }, { "name": "symfony/maker-bundle", - "version": "v1.63.0", + "version": "v1.64.0", "source": { "type": "git", "url": "https://github.com/symfony/maker-bundle.git", - "reference": "69478ab39bc303abfbe3293006a78b09a8512425" + "reference": "c86da84640b0586e92aee2b276ee3638ef2f425a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/69478ab39bc303abfbe3293006a78b09a8512425", - "reference": "69478ab39bc303abfbe3293006a78b09a8512425", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/c86da84640b0586e92aee2b276ee3638ef2f425a", + "reference": "c86da84640b0586e92aee2b276ee3638ef2f425a", "shasum": "" }, "require": { @@ -13361,6 +13565,7 @@ "symfony/http-client": "^6.4|^7.0", "symfony/phpunit-bridge": "^6.4.1|^7.0", "symfony/security-core": "^6.4|^7.0", + "symfony/security-http": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0", "twig/twig": "^3.0|^4.x-dev" }, @@ -13396,7 +13601,7 @@ ], "support": { "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.63.0" + "source": "https://github.com/symfony/maker-bundle/tree/v1.64.0" }, "funding": [ { @@ -13412,7 +13617,7 @@ "type": "tidelift" } ], - "time": "2025-04-26T01:41:37+00:00" + "time": "2025-06-23T16:12:08+00:00" }, { "name": "symfony/polyfill-php80", @@ -13633,16 +13838,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "47c994d8f08817122ffb48bf2ea4fb97b7e00d51" + "reference": "c5e02451fe4e430c5067ddbf0899493522782390" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/47c994d8f08817122ffb48bf2ea4fb97b7e00d51", - "reference": "47c994d8f08817122ffb48bf2ea4fb97b7e00d51", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/c5e02451fe4e430c5067ddbf0899493522782390", + "reference": "c5e02451fe4e430c5067ddbf0899493522782390", "shasum": "" }, "require": { @@ -13698,7 +13903,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.3.1" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.3.2" }, "funding": [ { @@ -13709,12 +13914,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-05T09:30:41+00:00" + "time": "2025-07-26T16:47:03+00:00" }, { "name": "theofidry/alice-data-fixtures", diff --git a/docker-compose.yml b/docker-compose.yml index 88d602c3..6c9f6503 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,46 +21,75 @@ services: #- ENCRYPT=1 # Uncomment to enable database encryption. phpfpm: - image: itkdev/php8.4-fpm:latest + #image: dunglas/frankenphp + # uncomment the following line if you want to use a custom Dockerfile + build: . networks: - app + - frontend extra_hosts: - "host.docker.internal:host-gateway" environment: - - PHP_XDEBUG_MODE=${PHP_XDEBUG_MODE:-off} - - PHP_MAX_EXECUTION_TIME=30 - - PHP_MEMORY_LIMIT=256M - # Depending on the setup, you may have to remove --read-envelope-from from msmtp (cf. https://marlam.de/msmtp/msmtp.html) or use SMTP to send mail - - PHP_SENDMAIL_PATH=/usr/bin/msmtp --host=mail --port=1025 --read-recipients --read-envelope-from - - DOCKER_HOST_DOMAIN=${COMPOSE_DOMAIN} - - COMPOSER_VERSION=2 - - PHP_IDE_CONFIG=serverName=localhost - depends_on: - - mariadb - volumes: - - .:/app - - nginx: - image: nginxinc/nginx-unprivileged:alpine - networks: - - app - - frontend - depends_on: - - phpfpm + SERVER_NAME: ":8080" + #FRANKENPHP_CONFIG: "worker ./public/index.php" + #APP_RUNTIME: "Runtime\\FrankenPhpSymfony\\Runtime" ports: - '8080' volumes: - - ./.docker/templates:/etc/nginx/templates:ro - .:/app - environment: - NGINX_FPM_SERVICE: ${COMPOSE_PROJECT_NAME}-phpfpm-1:9000 - NGINX_WEB_ROOT: /app/public - NGINX_PORT: 8080 - NGINX_MAX_BODY_SIZE: 5M + - caddy_data:/data + - caddy_config:/config + # comment the following line in production, it allows to have nice human-readable logs in dev + tty: true labels: - "traefik.enable=true" - "traefik.docker.network=frontend" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${COMPOSE_DOMAIN}`)" + # HTTPS config - uncomment to enable redirect from :80 to :443 + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.middlewares=redirect-to-https" + - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + +# phpfpm: +# image: itkdev/php8.4-fpm:latest +# networks: +# - app +# extra_hosts: +# - "host.docker.internal:host-gateway" +# environment: +# - PHP_XDEBUG_MODE=${PHP_XDEBUG_MODE:-off} +# - PHP_MAX_EXECUTION_TIME=30 +# - PHP_MEMORY_LIMIT=256M +# # Depending on the setup, you may have to remove --read-envelope-from from msmtp (cf. https://marlam.de/msmtp/msmtp.html) or use SMTP to send mail +# - PHP_SENDMAIL_PATH=/usr/bin/msmtp --host=mail --port=1025 --read-recipients --read-envelope-from +# - DOCKER_HOST_DOMAIN=${COMPOSE_DOMAIN} +# - COMPOSER_VERSION=2 +# - PHP_IDE_CONFIG=serverName=localhost +# depends_on: +# - mariadb +# volumes: +# - .:/app +# +# nginx: +# image: nginxinc/nginx-unprivileged:alpine +# networks: +# - app +# - frontend +# depends_on: +# - phpfpm +# ports: +# - '8080' +# volumes: +# - ./.docker/templates:/etc/nginx/templates:ro +# - .:/app +# environment: +# NGINX_FPM_SERVICE: ${COMPOSE_PROJECT_NAME}-phpfpm-1:9000 +# NGINX_WEB_ROOT: /app/public +# NGINX_PORT: 8080 +# NGINX_MAX_BODY_SIZE: 5M +# labels: +# - "traefik.enable=true" +# - "traefik.docker.network=frontend" +# - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${COMPOSE_DOMAIN}`)" # HTTPS config - uncomment to enable redirect from :80 to :443 # - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.middlewares=redirect-to-https" # - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" @@ -78,3 +107,8 @@ services: - "traefik.docker.network=frontend" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}mail.rule=Host(`mail-${COMPOSE_DOMAIN}`)" - "traefik.http.services.${COMPOSE_PROJECT_NAME}mail.loadbalancer.server.port=8025" + +# Volumes needed for Caddy certificates and configuration +volumes: + caddy_data: + caddy_config: \ No newline at end of file From ebd3fd472dda1fb5cfd3c95d63cd873497baa1ae Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 12:58:51 +0200 Subject: [PATCH 02/14] docs: add changelog entry for FrankenPHP POC Bring the POC compose config in line with develop: required-variable syntax, mariadb healthcheck dependency, protected /health/detail route, and the markdownlint/prettier dev services. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ada776..b6291c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#59](https://github.com/itk-dev/devops_itksites/pull/59) + 4544: POC for using FrankenPHP behind Traefik + - Replace the phpfpm and nginx pair with a single FrankenPHP service + built from a `Dockerfile` + - Worker mode stays off: `runtime/frankenphp-symfony` has no Symfony 8 + release - [#96](https://github.com/itk-dev/devops_itksites/pull/96) Show the Service Agreements monthly price as Danish kroner, `12.500,50 kr.`, on index and detail From c3f1e48636e3af1ac9da3d7682524f3be387904b Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 13:27:10 +0200 Subject: [PATCH 03/14] feat: serve the site from FrankenPHP behind Traefik Replace the phpfpm and nginx pair with a single FrankenPHP container, added in the per-environment override files, and move the stack to PHP 8.5. Changes - Add the `frankenphp` service in `docker-compose.override.yml` and `docker-compose.server.override.yml`, and park `phpfpm` and `nginx` in a profile that is never enabled - Port the nginx configuration to `.docker/Caddyfile` and the PHP settings the fpm image derives from `PHP_*` variables to `.docker/php.ini` - Keep TLS termination in Traefik: `auto_https` is off and Caddy serves plain HTTP on 8080 - Build on the published `dunglas/frankenphp:1.12-php8.5` image, adding the extensions it omits: pdo_mysql, amqp and intl - Move `itkdev/php8.5-fpm`, `itkdev/supervisor-php8.5` and the composer platform requirement to PHP 8.5 - Point Taskfile, workflows, Woodpecker, the staging and redirect overrides and the docs at the `frankenphp` service Why The POC was a year behind develop and pinned to Symfony 7. Putting the service in the override files keeps the base compose files as the template ships them, so the swap is one file per environment rather than a rewrite. --- .docker/Caddyfile | 77 ++++++++++++++++++++++ .docker/php.ini | 36 ++++++++++ .github/workflows/api-spec.yaml | 2 +- .github/workflows/composer.yaml | 16 ++--- .github/workflows/doctrine.yaml | 24 +++---- .github/workflows/github_build_release.yml | 4 +- .github/workflows/php.yaml | 14 ++-- .github/workflows/pr.yaml | 14 ++-- .github/workflows/twig.yaml | 10 +-- .woodpecker/prod.yml | 6 +- .woodpecker/stg.yml | 8 +-- CHANGELOG.md | 17 +++-- Dockerfile | 27 +++++--- README.md | 41 ++++++++++-- Taskfile.yml | 22 +++---- claude.md | 20 +++--- composer.json | 2 +- composer.lock | 4 +- docker-compose.dev.yml | 6 +- docker-compose.override.yml | 72 ++++++++++++++++++++ docker-compose.redirect.yml | 2 +- docker-compose.server.override.yml | 72 ++++++++++++++++++-- docker-compose.server.prod.yml | 2 +- docker-compose.server.yml | 2 +- docker-compose.yml | 57 ++++++++-------- 25 files changed, 427 insertions(+), 130 deletions(-) create mode 100644 .docker/Caddyfile create mode 100644 .docker/php.ini diff --git a/.docker/Caddyfile b/.docker/Caddyfile new file mode 100644 index 00000000..22cdac30 --- /dev/null +++ b/.docker/Caddyfile @@ -0,0 +1,77 @@ +# FrankenPHP configuration for the POC. Ported from .docker/nginx.conf and +# .docker/templates/default.conf.template. +# +# Mounted over the image default at /etc/frankenphp/Caddyfile. +{ + # Traefik terminates TLS and Caddy is only ever reached over plain HTTP on + # the app network, so Caddy must neither request nor serve certificates. + auto_https off + skip_install_trust + + servers { + # set_real_ip_from / real_ip_recursive / real_ip_header X-Forwarded-For + trusted_proxies static 172.16.0.0/16 192.168.39.0/24 + client_ip_headers X-Forwarded-For + } + + frankenphp { + {$FRANKENPHP_CONFIG} + } +} + +# A bare port means no hostname, and so no certificate handling. +{$SERVER_NAME::8080} { + root {$SERVER_ROOT:/app/public} + + # gzip on + encode zstd br gzip + + # error_log /dev/stderr, access_log /dev/stdout + log { + output stdout + } + + # client_max_body_size + request_body { + max_size {$PHP_MAX_BODY_SIZE:5MB} + } + + # Proxy to supercronic metrics. The upstream is a sidecar this project does + # not run yet, exactly as with nginx, where NGINX_CRON_METRICS pointed at a + # port nothing listened on. + handle /cron-metrics { + rewrite * /metrics + reverse_proxy {$CRON_METRICS_UPSTREAM:localhost:9746} + } + + # Protect files and directories from prying eyes. + # + # The nginx version leans on a negative lookahead to let /.well-known + # through. RE2, which Caddy uses, has no lookaheads, so the exception is a + # matcher of its own instead. + # + # Note that the extension list covers yml but not yaml: public/ serves + # api-spec-v1.yaml. + @hidden { + path_regexp hidden /\. + not path /.well-known/* + } + respond @hidden 404 + + @protected path_regexp protected (?i)(\.(engine|inc|install|make|module|profile|po|sh|.*sql|tar|gz|bz2|theme|twig|tpl(\.php)?|xtmpl|yml)(~|\.sw[op]|\.bak|\.orig|\.save)?|/(Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config)|/#[^/]*#|\.php(~|\.sw[op]|\.bak|\.orig|\.save))$ + respond @protected 404 + + # location ~ \.php$ { return 404; } plus the `internal` on the front + # controller: no .php path is reachable from outside, /index.php and + # /index.php/… included. The matcher sees the request as it arrived, so the + # rewrite php_server does further down is unaffected. + route { + @directPhp path *.php *.php/* + respond @directPhp 404 + + # try_files $uri /index.php$is_args$args + php_server { + #worker /app/public/index.php + } + } +} diff --git a/.docker/php.ini b/.docker/php.ini new file mode 100644 index 00000000..038050b5 --- /dev/null +++ b/.docker/php.ini @@ -0,0 +1,36 @@ +; PHP settings for the FrankenPHP POC, mounted into +; /usr/local/etc/php/conf.d/. +; +; The itkdev/php8.4-fpm image turns a set of PHP_* environment variables into +; ini settings and ships a tuned baseline on top of php.ini-production. The +; published FrankenPHP image does neither, so both are reproduced here. Every +; variable is given a value in the docker-compose.frankenphp.* files — an unset +; one expands to the empty string and PHP warns. + +; Set as env on itkdev/php8.4-fpm. +memory_limit = ${PHP_MEMORY_LIMIT} +max_execution_time = ${PHP_MAX_EXECUTION_TIME} +sendmail_path = ${PHP_SENDMAIL_PATH} + +; itkdev/php8.4-fpm baseline. The FrankenPHP image leaves these at the +; development defaults: UTC, errors to the response body, no error log. +date.timezone = Europe/Copenhagen +display_errors = Off +display_startup_errors = Off +log_errors = On +error_reporting = E_ALL & ~E_DEPRECATED +expose_php = Off + +opcache.memory_consumption = 64 +opcache.max_accelerated_files = 20000 +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 + +realpath_cache_size = 4096k +realpath_cache_ttl = 600 + +; PHP_XDEBUG_MODE and PHP_XDEBUG_WITH_REQUEST are what itkdev-docker-compose +; sets when starting with a debugger attached. +xdebug.mode = ${PHP_XDEBUG_MODE} +xdebug.start_with_request = ${PHP_XDEBUG_WITH_REQUEST} +xdebug.client_host = host.docker.internal diff --git a/.github/workflows/api-spec.yaml b/.github/workflows/api-spec.yaml index 4b39553d..8e09a99a 100644 --- a/.github/workflows/api-spec.yaml +++ b/.github/workflows/api-spec.yaml @@ -53,7 +53,7 @@ jobs: Please run the following command, then commit and push the changes: ```shell - docker compose exec phpfpm composer update-api-spec + docker compose exec frankenphp composer update-api-spec ``` EOF )" \ diff --git a/.github/workflows/composer.yaml b/.github/workflows/composer.yaml index 26a728a3..dadd99a2 100644 --- a/.github/workflows/composer.yaml +++ b/.github/workflows/composer.yaml @@ -8,19 +8,19 @@ ### ### #### Assumptions ### -### 1. A docker compose service named `phpfpm` can be run and `composer` can be -### run inside the `phpfpm` service. +### 1. A docker compose service named `frankenphp` can be run and `composer` can be +### run inside the `frankenphp` service. ### 2. [ergebnis/composer-normalize](https://github.com/ergebnis/composer-normalize) ### is a dev requirement in `composer.json`: ### ### ``` shell -### docker compose run --rm phpfpm composer require --dev ergebnis/composer-normalize +### docker compose run --rm frankenphp composer require --dev ergebnis/composer-normalize ### ``` ### ### Normalize `composer.json` by running ### ### ``` shell -### docker compose run --rm phpfpm composer normalize +### docker compose run --rm frankenphp composer normalize ### ``` name: Composer @@ -51,7 +51,7 @@ jobs: docker network create frontend - run: | - docker compose run --rm phpfpm composer validate --strict + docker compose run --rm frankenphp composer validate --strict composer-normalized: runs-on: ubuntu-latest @@ -63,8 +63,8 @@ jobs: docker network create frontend - run: | - docker compose run --rm phpfpm composer install - docker compose run --rm phpfpm composer normalize --dry-run + docker compose run --rm frankenphp composer install + docker compose run --rm frankenphp composer normalize --dry-run composer-audit: runs-on: ubuntu-latest @@ -76,4 +76,4 @@ jobs: docker network create frontend - run: | - docker compose run --rm phpfpm composer audit --locked + docker compose run --rm frankenphp composer audit --locked diff --git a/.github/workflows/doctrine.yaml b/.github/workflows/doctrine.yaml index 8eb28dd9..b5af7aae 100644 --- a/.github/workflows/doctrine.yaml +++ b/.github/workflows/doctrine.yaml @@ -26,19 +26,19 @@ jobs: - name: Run Composer Install run: | - docker compose run --rm phpfpm composer install + docker compose run --rm frankenphp composer install - name: Run Doctrine Migrations run: | - docker compose run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction + docker compose run --rm frankenphp bin/console doctrine:migrations:migrate --no-interaction - name: Setup messenger "failed" doctrine transport to ensure db schema is updated run: | - docker compose run --rm phpfpm bin/console messenger:setup-transports failed + docker compose run --rm frankenphp bin/console messenger:setup-transports failed - name: Validate Doctrine schema run: | - docker compose run --rm phpfpm bin/console doctrine:schema:validate + docker compose run --rm frankenphp bin/console doctrine:schema:validate load-fixtures: name: Load Doctrine fixtures @@ -53,15 +53,15 @@ jobs: - name: Run Composer Install run: | - docker compose run --rm phpfpm composer install --no-interaction + docker compose run --rm frankenphp composer install --no-interaction - name: Run Doctrine Migrations run: | - docker compose run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction + docker compose run --rm frankenphp bin/console doctrine:migrations:migrate --no-interaction - name: Load fixtures run: | - docker compose run --rm phpfpm composer fixtures + docker compose run --rm frankenphp composer fixtures # The jobs above migrate an empty database. A deployment migrates a database # that already holds rows, so a migration that cannot cope with existing @@ -92,15 +92,15 @@ jobs: - name: Run Composer Install run: | - docker compose run --rm phpfpm composer install --no-interaction + docker compose run --rm frankenphp composer install --no-interaction - name: Run Doctrine Migrations run: | - docker compose run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction + docker compose run --rm frankenphp bin/console doctrine:migrations:migrate --no-interaction - name: Load fixtures run: | - docker compose run --rm phpfpm composer fixtures + docker compose run --rm frankenphp composer fixtures - name: Check out the pull request run: | @@ -108,8 +108,8 @@ jobs: - name: Run Composer Install run: | - docker compose run --rm phpfpm composer install --no-interaction + docker compose run --rm frankenphp composer install --no-interaction - name: Run Doctrine Migrations on the populated database run: | - docker compose run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction + docker compose run --rm frankenphp bin/console doctrine:migrations:migrate --no-interaction diff --git a/.github/workflows/github_build_release.yml b/.github/workflows/github_build_release.yml index ba366729..9a6dac83 100644 --- a/.github/workflows/github_build_release.yml +++ b/.github/workflows/github_build_release.yml @@ -21,8 +21,8 @@ jobs: - name: Composer install run: | docker network create frontend - docker compose run --rm --user=root --env APP_ENV=prod phpfpm composer install --no-dev -o --classmap-authoritative - docker compose run --rm --user=root --env APP_ENV=prod phpfpm composer clear-cache + docker compose run --rm --user=root --env APP_ENV=prod frankenphp composer install --no-dev -o --classmap-authoritative + docker compose run --rm --user=root --env APP_ENV=prod frankenphp composer clear-cache docker compose run --rm node yarn install docker compose run --rm node yarn build diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml index 2b958dc6..7bc829d1 100644 --- a/.github/workflows/php.yaml +++ b/.github/workflows/php.yaml @@ -9,20 +9,20 @@ ### ### #### Assumptions ### -### 1. A docker compose service named `phpfpm` can be run and `composer` can be -### run inside the `phpfpm` service. 2. +### 1. A docker compose service named `frankenphp` can be run and `composer` can be +### run inside the `frankenphp` service. 2. ### [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) ### is a dev requirement in `composer.json`: ### ### ``` shell -### docker compose run --rm phpfpm composer require --dev friendsofphp/php-cs-fixer +### docker compose run --rm frankenphp composer require --dev friendsofphp/php-cs-fixer ### ``` ### ### Clean up and check code by running ### ### ``` shell -### docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix -### docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix --dry-run --diff +### docker compose run --rm frankenphp vendor/bin/php-cs-fixer fix +### docker compose run --rm frankenphp vendor/bin/php-cs-fixer fix --dry-run --diff ### ``` ### ### > [!NOTE] The template adds `.php-cs-fixer.dist.php` as [a configuration @@ -61,6 +61,6 @@ jobs: docker network create frontend - run: | - docker compose run --rm phpfpm composer install + docker compose run --rm frankenphp composer install # https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/usage.rst#the-check-command - docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix --dry-run --diff + docker compose run --rm frankenphp vendor/bin/php-cs-fixer fix --dry-run --diff diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index a726d00f..682e4b4f 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -15,8 +15,8 @@ jobs: - name: Run PHPStan run: | - docker compose run --rm phpfpm composer install --no-interaction - docker compose run --rm phpfpm vendor/bin/phpstan analyse + docker compose run --rm frankenphp composer install --no-interaction + docker compose run --rm frankenphp vendor/bin/phpstan analyse phpunit: runs-on: ubuntu-latest @@ -31,11 +31,11 @@ jobs: - name: Run tests with coverage run: | docker compose up --detach - docker compose exec -e XDEBUG_MODE=coverage phpfpm composer install --no-interaction - docker compose exec -e XDEBUG_MODE=coverage phpfpm bin/console --env=test doctrine:database:drop --if-exists --force --quiet - docker compose exec -e XDEBUG_MODE=coverage phpfpm bin/console --env=test doctrine:database:create --no-interaction --if-not-exists --quiet - docker compose exec -e XDEBUG_MODE=coverage phpfpm bin/console --env=test doctrine:migrations:migrate --no-interaction --quiet - docker compose exec -e XDEBUG_MODE=coverage phpfpm vendor/bin/phpunit --coverage-clover=coverage/unit.xml + docker compose exec -e XDEBUG_MODE=coverage frankenphp composer install --no-interaction + docker compose exec -e XDEBUG_MODE=coverage frankenphp bin/console --env=test doctrine:database:drop --if-exists --force --quiet + docker compose exec -e XDEBUG_MODE=coverage frankenphp bin/console --env=test doctrine:database:create --no-interaction --if-not-exists --quiet + docker compose exec -e XDEBUG_MODE=coverage frankenphp bin/console --env=test doctrine:migrations:migrate --no-interaction --quiet + docker compose exec -e XDEBUG_MODE=coverage frankenphp vendor/bin/phpunit --coverage-clover=coverage/unit.xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v7 diff --git a/.github/workflows/twig.yaml b/.github/workflows/twig.yaml index 1d7cd59a..6000faae 100644 --- a/.github/workflows/twig.yaml +++ b/.github/workflows/twig.yaml @@ -8,13 +8,13 @@ ### ### #### Assumptions ### -### 1. A docker compose service named `phpfpm` can be run and `composer` can be -### run inside the `phpfpm` service. +### 1. A docker compose service named `frankenphp` can be run and `composer` can be +### run inside the `frankenphp` service. ### 2. [vincentlanglet/twig-cs-fixer](https://github.com/VincentLanglet/Twig-CS-Fixer) ### is a dev requirement in `composer.json`: ### ### ``` shell -### docker compose run --rm phpfpm composer require --dev vincentlanglet/twig-cs-fixer +### docker compose run --rm frankenphp composer require --dev vincentlanglet/twig-cs-fixer ### ``` ### ### 3. A [Configuration @@ -51,5 +51,5 @@ jobs: docker network create frontend - run: | - docker compose run --rm phpfpm composer install - docker compose run --rm phpfpm vendor/bin/twig-cs-fixer lint + docker compose run --rm frankenphp composer install + docker compose run --rm frankenphp vendor/bin/twig-cs-fixer lint diff --git a/.woodpecker/prod.yml b/.woodpecker/prod.yml index e38d7e78..077b0bf6 100644 --- a/.woodpecker/prod.yml +++ b/.woodpecker/prod.yml @@ -24,8 +24,8 @@ steps: keep: 4 playbook: "release" pre_up: - - itkdev-docker-compose-server run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction - - itkdev-docker-compose-server run --rm phpfpm bin/console messenger:setup-transports + - itkdev-docker-compose-server run --rm frankenphp bin/console doctrine:migrations:migrate --no-interaction + - itkdev-docker-compose-server run --rm frankenphp bin/console messenger:setup-transports - name: Run post deploy image: itkdev/ansible-plugin:1 @@ -42,4 +42,4 @@ steps: user: from_secret: user actions: - - itkdev-docker-compose-server exec phpfpm bin/console cache:clear + - itkdev-docker-compose-server exec frankenphp bin/console cache:clear diff --git a/.woodpecker/stg.yml b/.woodpecker/stg.yml index 0a5749a4..9df99156 100644 --- a/.woodpecker/stg.yml +++ b/.woodpecker/stg.yml @@ -31,7 +31,7 @@ steps: - git checkout ${CI_COMMIT_BRANCH} - git pull - itkdev-docker-compose-server up -d --force-recreate --remove-orphans - - itkdev-docker-compose-server exec phpfpm composer install -no-dev -o --classmap-authoritative - - itkdev-docker-compose-server exec phpfpm bin/console doctrine:migrations:migrate --no-interaction - - itkdev-docker-compose-server exec phpfpm bin/console messenger:setup-transports - - itkdev-docker-compose-server exec phpfpm bin/console cache:clear + - itkdev-docker-compose-server exec frankenphp composer install -no-dev -o --classmap-authoritative + - itkdev-docker-compose-server exec frankenphp bin/console doctrine:migrations:migrate --no-interaction + - itkdev-docker-compose-server exec frankenphp bin/console messenger:setup-transports + - itkdev-docker-compose-server exec frankenphp bin/console cache:clear diff --git a/CHANGELOG.md b/CHANGELOG.md index b6291c64..8659a78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#59](https://github.com/itk-dev/devops_itksites/pull/59) 4544: POC for using FrankenPHP behind Traefik - - Replace the phpfpm and nginx pair with a single FrankenPHP service - built from a `Dockerfile` - - Worker mode stays off: `runtime/frankenphp-symfony` has no Symfony 8 - release + - Serve the site from a single FrankenPHP container. The `frankenphp` service + is added in `docker-compose.override.yml` and + `docker-compose.server.override.yml`; `phpfpm` and `nginx` move into a + profile that is never enabled + - Port the nginx configuration to `.docker/Caddyfile` and the PHP settings the + fpm image took from `PHP_*` environment variables to `.docker/php.ini` + - Traefik keeps terminating TLS: `auto_https` is off and Caddy serves plain + HTTP on 8080 + - Move the whole stack to PHP 8.5, `itkdev/php8.5-fpm` and + `itkdev/supervisor-php8.5` included + - Point Taskfile, workflows, Woodpecker and the README at the `frankenphp` + service + - Worker mode stays off: `runtime/frankenphp-symfony` has no Symfony 8 release - [#96](https://github.com/itk-dev/devops_itksites/pull/96) Show the Service Agreements monthly price as Danish kroner, `12.500,50 kr.`, on index and detail diff --git a/Dockerfile b/Dockerfile index f155f994..684a97c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,25 @@ -FROM dunglas/frankenphp +# FrankenPHP POC. +# +# The published dunglas/frankenphp images are deliberately minimal, so the +# extensions this application cannot boot without are added on top. Everything +# else it needs — ctype, iconv, dom, mbstring, opcache, … — is already in the +# base image. +# Pinned to PHP 8.5 to match itkdev/php8.5-fpm and itkdev/supervisor-php8.5: +# the messenger worker and the web container share vendor/ and var/cache over +# the same bind mount, so they have to agree on the PHP version. +FROM dunglas/frankenphp:1.12-php8.5 -# add additional extensions here: RUN install-php-extensions \ pdo_mysql \ - gd \ + amqp \ intl \ + gd \ zip \ - opcache \ - amqp \ - xdebug + xdebug + +# msmtp keeps sendmail_path working the way it does in itkdev/php8.5-fpm. +RUN apt-get update \ + && apt-get install --no-install-recommends --yes msmtp \ + && rm -rf /var/lib/apt/lists/* -# Install composer -COPY --from=composer/composer:2-bin /composer /usr/bin/composer \ No newline at end of file +COPY --from=composer/composer:2-bin /composer /usr/bin/composer diff --git a/README.md b/README.md index 64b54161..318c8f53 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Authenticated users can access a simple read-only API – see the API documentat Run the `app:user:set-api-key` console command to set the API for a user: ``` shell -docker compose exec phpfpm php bin/console app:user:set-api-key +docker compose exec frankenphp php bin/console app:user:set-api-key ``` Use the API key to make an authenticated request, e.g. @@ -105,12 +105,39 @@ that the database is down. ```sh docker compose pull docker compose up --detach -docker compose exec phpfpm composer install -docker compose exec phpfpm bin/console doctrine:migrations:migrate --no-interaction +docker compose exec frankenphp composer install +docker compose exec frankenphp bin/console doctrine:migrations:migrate --no-interaction ``` Then create a `.env.local` file to set secrets for your local setup. +### Web server + +The site is served by a single [FrankenPHP](https://frankenphp.dev) container +instead of the usual phpfpm and nginx pair. `docker-compose.override.yml` +locally, and `docker-compose.server.override.yml` on the servers, add the +`frankenphp` service and park `phpfpm` and `nginx` in a profile that is never +enabled, so neither starts. Commands that used to run against `phpfpm` run +against `frankenphp`. + +Traefik still terminates TLS. Caddy listens on plain HTTP on port 8080 and +`auto_https` is off, so it neither requests nor serves certificates. + +Two files carry the configuration that used to live on the phpfpm and nginx +images: + +- `.docker/Caddyfile` – a port of `.docker/nginx.conf` and + `.docker/templates/default.conf.template`. +- `.docker/php.ini` – the PHP settings the `itkdev/php8.5-fpm` image derives + from its `PHP_*` environment variables, plus its tuned baseline. The compose + files still set the same variables; the ini file interpolates them. + +Coming from the phpfpm stack, remove the containers it left behind once: + +```sh +docker compose rm --stop --force phpfpm nginx +``` + ### OpenID Connect All users access is controlled by OpenID Connect. For local development you must @@ -145,13 +172,13 @@ all the above data. #### Load fixtures ```sh -docker compose exec phpfpm composer fixtures +docker compose exec frankenphp composer fixtures ``` After loading fixtures you can sign in as an admin user: ```sh -docker compose exec phpfpm bin/console itk-dev:openid-connect:login admin@example.com +docker compose exec frankenphp bin/console itk-dev:openid-connect:login admin@example.com ``` ### Job queues and handlers @@ -160,13 +187,13 @@ All processing of Detctionresults is done in a series of message handlers. To run these do either: ```shell -docker compose exec phpfpm composer queues +docker compose exec frankenphp composer queues ``` or ```shell -docker compose exec phpfpm bin/console messenger:consume async --failure-limit=1 -vvv +docker compose exec frankenphp bin/console messenger:consume async --failure-limit=1 -vvv ``` ### Assets diff --git a/Taskfile.yml b/Taskfile.yml index 108d803a..bac9ebcc 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -52,7 +52,7 @@ tasks: CONSOLE_ARGS: --env=test doctrine:migrations:migrate --no-interaction --quiet - task: compose vars: - COMPOSE_ARGS: exec phpfpm vendor/bin/phpunit --stop-on-failure + COMPOSE_ARGS: exec frankenphp vendor/bin/phpunit --stop-on-failure queues: desc: Consume async messenger queue @@ -66,7 +66,7 @@ tasks: cmds: - task: compose vars: - COMPOSE_ARGS: exec phpfpm composer {{.COMPOSER_ARGS}} + COMPOSE_ARGS: exec frankenphp composer {{.COMPOSER_ARGS}} compose: desc: "Run `docker compose` command. Example: task compose -- ps" @@ -78,7 +78,7 @@ tasks: cmds: - task: compose vars: - COMPOSE_ARGS: exec phpfpm bin/console {{.CONSOLE_ARGS}} + COMPOSE_ARGS: exec frankenphp bin/console {{.CONSOLE_ARGS}} coding-standards:apply: aliases: [cs:apply] @@ -123,7 +123,7 @@ tasks: cmds: - task: compose vars: - COMPOSE_ARGS: exec phpfpm vendor/bin/php-cs-fixer fix + COMPOSE_ARGS: exec frankenphp vendor/bin/php-cs-fixer fix silent: true coding-standards:php:check: @@ -133,7 +133,7 @@ tasks: - task: coding-standards:php:apply - task: compose vars: - COMPOSE_ARGS: exec phpfpm vendor/bin/php-cs-fixer check + COMPOSE_ARGS: exec frankenphp vendor/bin/php-cs-fixer check silent: true coding-standards:twig:apply: @@ -142,7 +142,7 @@ tasks: cmds: - task: compose vars: - COMPOSE_ARGS: exec phpfpm vendor/bin/twig-cs-fixer lint --fix + COMPOSE_ARGS: exec frankenphp vendor/bin/twig-cs-fixer lint --fix silent: true coding-standards:twig:check: @@ -152,7 +152,7 @@ tasks: - task: coding-standards:twig:apply - task: compose vars: - COMPOSE_ARGS: exec phpfpm vendor/bin/twig-cs-fixer lint + COMPOSE_ARGS: exec frankenphp vendor/bin/twig-cs-fixer lint silent: true coding-standards:yaml:apply: @@ -181,13 +181,13 @@ tasks: cmds: - task: compose vars: - COMPOSE_ARGS: exec phpfpm composer validate --strict + COMPOSE_ARGS: exec frankenphp composer validate --strict - task: compose vars: - COMPOSE_ARGS: exec phpfpm composer normalize --dry-run + COMPOSE_ARGS: exec frankenphp composer normalize --dry-run - task: compose vars: - COMPOSE_ARGS: exec phpfpm composer audit + COMPOSE_ARGS: exec frankenphp composer audit # Analysis @@ -197,7 +197,7 @@ tasks: cmds: - task: compose vars: - COMPOSE_ARGS: exec phpfpm vendor/bin/phpstan + COMPOSE_ARGS: exec frankenphp vendor/bin/phpstan silent: true # Test matrix (mirrors pr.yaml CI jobs) diff --git a/claude.md b/claude.md index a83a1af7..30ea1d19 100644 --- a/claude.md +++ b/claude.md @@ -63,23 +63,23 @@ truncated and rebuilt by replaying DetectionResults. Manually maintained data ## Development Environment ```sh -# Start services (MariaDB, PHP-FPM 8.5, Nginx, Mailpit) +# Start services (MariaDB, FrankenPHP 8.5, Mailpit) docker compose pull && docker compose up --detach # Install dependencies -docker compose exec phpfpm composer install +docker compose exec frankenphp composer install # Run migrations -docker compose exec phpfpm bin/console doctrine:migrations:migrate --no-interaction +docker compose exec frankenphp bin/console doctrine:migrations:migrate --no-interaction # Load fixtures -docker compose exec phpfpm composer fixtures +docker compose exec frankenphp composer fixtures # Login as admin (after fixtures) -docker compose exec phpfpm bin/console itk-dev:openid-connect:login admin@example.com +docker compose exec frankenphp bin/console itk-dev:openid-connect:login admin@example.com # Process message queues -docker compose exec phpfpm composer queues +docker compose exec frankenphp composer queues # Build frontend assets docker compose run --rm node yarn install && docker compose run --rm node yarn build @@ -91,17 +91,17 @@ All commands run inside Docker containers: ```sh # PHP coding standards (PHP-CS-Fixer) -docker compose exec phpfpm composer coding-standards-check -docker compose exec phpfpm composer coding-standards-apply +docker compose exec frankenphp composer coding-standards-check +docker compose exec frankenphp composer coding-standards-apply # PHPUnit tests (creates test DB, runs migrations, executes tests) -docker compose exec phpfpm composer tests +docker compose exec frankenphp composer tests # Frontend coding standards docker compose run --rm node yarn coding-standards-check # API spec export (must be committed) -docker compose exec phpfpm composer update-api-spec +docker compose exec frankenphp composer update-api-spec ``` ## CI/CD diff --git a/composer.json b/composer.json index cf7804a7..2d9da4ed 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "license": "MIT", "type": "project", "require": { - "php": ">=8.4", + "php": ">=8.5", "ext-ctype": "*", "ext-iconv": "*", "api-platform/core": "^4.0", diff --git a/composer.lock b/composer.lock index 436ebc9d..e55b8582 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "896639a35d4c350fd3b2b3fdfe6542fc", + "content-hash": "ccbb3b812101b1a66fcae6a49c4c95d0", "packages": [ { "name": "api-platform/core", @@ -14375,7 +14375,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": ">=8.4", + "php": ">=8.5", "ext-ctype": "*", "ext-iconv": "*" }, diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 71a88b42..cbfd97f9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,10 +1,8 @@ # itk-version: 3.2.4 services: - phpfpm: + frankenphp: environment: - - PHP_SENDMAIL_PATH=/usr/sbin/sendmail -S mail:1025 - - nginx: + PHP_SENDMAIL_PATH: /usr/sbin/sendmail -S mail:1025 labels: - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.middlewares=ITKBasicAuth@file" diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 73f8b43f..3d0ddb57 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -1,4 +1,71 @@ services: + # FrankenPHP POC: a single container replaces the phpfpm and nginx pair. + # Compose cannot delete an inherited service, so both are moved into a + # profile that is never enabled. Nothing depends on them once nginx is gone, + # so neither is started. + # + # Commands documented against phpfpm run against frankenphp instead, e.g. + # `docker compose exec frankenphp composer install`. + phpfpm: + profiles: + - replaced-by-frankenphp + + nginx: + profiles: + - replaced-by-frankenphp + + frankenphp: + build: . + networks: + - app + - frontend + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + # A bare port keeps Caddy on plain HTTP; Traefik terminates TLS. + SERVER_NAME: ":8080" + SERVER_ROOT: /app/public + PHP_MAX_BODY_SIZE: 5MB + CRON_METRICS_UPSTREAM: ${COMPOSE_PROJECT_NAME:?}-frankenphp-1:9746 + # Read by .docker/php.ini. Same values the itkdev/php8.5-fpm image gets + # from its own env handling. + PHP_MEMORY_LIMIT: 256M + PHP_MAX_EXECUTION_TIME: "30" + # Depending on the setup, you may have to remove --read-envelope-from from msmtp (cf. https://marlam.de/msmtp/msmtp.html) or use SMTP to send mail + PHP_SENDMAIL_PATH: /usr/bin/msmtp --host=mail --port=1025 --read-recipients --read-envelope-from + PHP_XDEBUG_MODE: ${PHP_XDEBUG_MODE:-off} + PHP_XDEBUG_WITH_REQUEST: ${PHP_XDEBUG_WITH_REQUEST:-no} + DOCKER_HOST_DOMAIN: ${COMPOSE_DOMAIN:?} + PHP_IDE_CONFIG: serverName=localhost + depends_on: + mariadb: + condition: service_healthy + ports: + - "8080" + volumes: + - .:/app + - ./.docker/Caddyfile:/etc/frankenphp/Caddyfile:ro + - ./.docker/php.ini:/usr/local/etc/php/conf.d/zz-app.ini:ro + - caddy_data:/data + - caddy_config:/config + labels: + - "traefik.enable=true" + - "traefik.docker.network=frontend" + # The image exposes 80, 443 and 2019 as well, so the port Traefik should + # talk to has to be spelled out. + - "traefik.http.services.${COMPOSE_PROJECT_NAME:?}.loadbalancer.server.port=8080" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.rule=Host(`${COMPOSE_DOMAIN:?}`)" + # HTTPS config - uncomment to enable redirect from :80 to :443 + # - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.middlewares=redirect-to-https" + # - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + # Cron-metrics protection. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" + # Detailed health check protection. /health/live and /health/ready stay + # public; only /health/detail discloses internals. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/health/detail`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.middlewares=ITKBasicAuth@file" + rabbit: image: rabbitmq:4-management-alpine networks: @@ -23,3 +90,8 @@ services: volumes: - .:/app working_dir: /app + +# Caddy keeps its instance identity and last known config here. +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.redirect.yml b/docker-compose.redirect.yml index 2e7ac332..c649c6dc 100644 --- a/docker-compose.redirect.yml +++ b/docker-compose.redirect.yml @@ -1,6 +1,6 @@ # itk-version: 3.2.4 services: - nginx: + frankenphp: labels: # Add www before domain and set redirect to non-www - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME:?}-http.rule=Host(`www.${COMPOSE_SERVER_DOMAIN:?}`)" diff --git a/docker-compose.server.override.yml b/docker-compose.server.override.yml index cbfa1813..6d25d36a 100644 --- a/docker-compose.server.override.yml +++ b/docker-compose.server.override.yml @@ -1,4 +1,66 @@ services: + # FrankenPHP POC: a single container replaces the phpfpm and nginx pair. + # Compose cannot delete an inherited service, so both are moved into a + # profile that is never enabled. Nothing depends on them once nginx is gone, + # so neither is started. + phpfpm: + profiles: + - replaced-by-frankenphp + + nginx: + profiles: + - replaced-by-frankenphp + + frankenphp: + build: . + restart: unless-stopped + networks: + - app + - frontend + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + # A bare port keeps Caddy on plain HTTP; Traefik terminates TLS. + SERVER_NAME: ":8080" + SERVER_ROOT: /app/public + PHP_MAX_BODY_SIZE: 5MB + CRON_METRICS_UPSTREAM: ${COMPOSE_PROJECT_NAME:?}-frankenphp-1:9746 + # Read by .docker/php.ini. + PHP_MEMORY_LIMIT: 128M + PHP_MAX_EXECUTION_TIME: "30" + PHP_SENDMAIL_PATH: /usr/sbin/sendmail -t -i + PHP_XDEBUG_MODE: "off" + PHP_XDEBUG_WITH_REQUEST: "no" + depends_on: + rabbit: + condition: service_healthy + volumes: + - .:/app + - ./.docker/Caddyfile:/etc/frankenphp/Caddyfile:ro + - ./.docker/php.ini:/usr/local/etc/php/conf.d/zz-app.ini:ro + - caddy_data:/data + - caddy_config:/config + labels: + - "traefik.enable=true" + - "traefik.docker.network=frontend" + # The image exposes 80, 443 and 2019 as well, so the port Traefik should + # talk to has to be spelled out. + - "traefik.http.services.${COMPOSE_PROJECT_NAME:?}.loadbalancer.server.port=8080" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-http.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-http.entrypoints=web" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-http.middlewares=redirect-to-https" + - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.entrypoints=websecure" + # Cron-metrics protection. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" + # Detailed health check protection. /health/live and /health/ready stay + # public; only /health/detail discloses internals. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`) && PathPrefix(`/health/detail`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.entrypoints=websecure" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.middlewares=ITKBasicAuth@file" + rabbit: image: rabbitmq:4-management-alpine restart: unless-stopped @@ -18,7 +80,7 @@ services: retries: 30 supervisor: - image: itkdev/supervisor-php8.4:alpine + image: itkdev/supervisor-php8.5:alpine restart: unless-stopped stop_grace_period: 20s environment: @@ -36,7 +98,7 @@ services: rabbit: condition: service_healthy - phpfpm: - depends_on: - rabbit: - condition: service_healthy +# Caddy keeps its instance identity and last known config here. +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.server.prod.yml b/docker-compose.server.prod.yml index 8db1cf8a..cd85e345 100644 --- a/docker-compose.server.prod.yml +++ b/docker-compose.server.prod.yml @@ -3,6 +3,6 @@ services: volumes: - ../../shared/.env.local:/app/.env.local - phpfpm: + frankenphp: volumes: - ../../shared/.env.local:/app/.env.local diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 45f2720c..acfc9fca 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -8,7 +8,7 @@ networks: services: phpfpm: - image: itkdev/php8.4-fpm:alpine + image: itkdev/php8.5-fpm:alpine restart: unless-stopped networks: - app diff --git a/docker-compose.yml b/docker-compose.yml index 7cc9d6ba..4498a61e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,45 +26,55 @@ services: - MYSQL_DATABASE=db #- ENCRYPT=1 # Uncomment to enable database encryption. - # FrankenPHP replaces the phpfpm + nginx pair. See docker-compose.yml on - # develop for the setup this POC is replacing. phpfpm: - #image: dunglas/frankenphp - # uncomment the following line if you want to use a custom Dockerfile - build: . + image: itkdev/php8.5-fpm:latest + user: ${COMPOSE_USER:-deploy} networks: - app - - frontend extra_hosts: - "host.docker.internal:host-gateway" environment: - SERVER_NAME: ":8080" - # Worker mode needs runtime/frankenphp-symfony, which has no Symfony 8 - # release yet (see https://github.com/php-runtime/frankenphp-symfony). - #FRANKENPHP_CONFIG: "worker ./public/index.php" - #APP_RUNTIME: "Runtime\\FrankenPhpSymfony\\Runtime" + - PHP_XDEBUG_MODE=${PHP_XDEBUG_MODE:-off} + - PHP_MAX_EXECUTION_TIME=30 + - PHP_MEMORY_LIMIT=256M + # Depending on the setup, you may have to remove --read-envelope-from from msmtp (cf. https://marlam.de/msmtp/msmtp.html) or use SMTP to send mail + - PHP_SENDMAIL_PATH=/usr/bin/msmtp --host=mail --port=1025 --read-recipients --read-envelope-from + - DOCKER_HOST_DOMAIN=${COMPOSE_DOMAIN:?} + - PHP_IDE_CONFIG=serverName=localhost depends_on: mariadb: condition: service_healthy + volumes: + - .:/app + + nginx: + image: nginxinc/nginx-unprivileged:alpine + networks: + - app + - frontend + depends_on: + - phpfpm ports: - "8080" volumes: + - ./.docker/templates:/etc/nginx/templates:ro - .:/app - - caddy_data:/data - - caddy_config:/config - # comment the following line in production, it allows to have nice human-readable logs in dev - tty: true + environment: + NGINX_FPM_SERVICE: ${COMPOSE_PROJECT_NAME:?}-phpfpm-1:9000 + NGINX_CRON_METRICS: ${COMPOSE_PROJECT_NAME:?}-phpfpm-1:9746 + NGINX_WEB_ROOT: /app/public + NGINX_PORT: 8080 + NGINX_MAX_BODY_SIZE: 5M labels: - "traefik.enable=true" - "traefik.docker.network=frontend" - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.rule=Host(`${COMPOSE_DOMAIN:?}`)" # HTTPS config - uncomment to enable redirect from :80 to :443 - - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.middlewares=redirect-to-https" - - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - # Cron-metrics protection. Disabled until the cron-metrics exporter - # (nginx proxied it to phpfpm:9746) is wired up in the Caddy config. - # - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " - # - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" + # - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.middlewares=redirect-to-https" + # - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + # Cron-metrics protection. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" # Detailed health check protection. /health/live and /health/ready stay # public; only /health/detail discloses internals. - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-health.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/health/detail`)" @@ -102,8 +112,3 @@ services: - dev volumes: - ./:/work - -# Volumes needed for Caddy certificates and configuration -volumes: - caddy_data: - caddy_config: From d9dbca184b87c7e203391139c678e5ffaf094185 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 13:32:25 +0200 Subject: [PATCH 04/14] ci: resolve the PHP service name per checked-out revision The populated-database job checks out the base branch before the pull request, so it sees two revisions of docker-compose.yml and cannot assume either service name. --- .github/workflows/doctrine.yaml | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/doctrine.yaml b/.github/workflows/doctrine.yaml index b5af7aae..49ee9691 100644 --- a/.github/workflows/doctrine.yaml +++ b/.github/workflows/doctrine.yaml @@ -90,26 +90,48 @@ jobs: run: | git checkout ${{ github.event.pull_request.base.sha }} + - name: Resolve the PHP service name + run: | + # This job straddles two revisions of docker-compose.yml: one + # of them may still call the PHP service phpfpm while the + # other calls it frankenphp. + if docker compose config --services | grep -qx frankenphp; then + echo "PHP_SERVICE=frankenphp" >> "$GITHUB_ENV" + else + echo "PHP_SERVICE=phpfpm" >> "$GITHUB_ENV" + fi + - name: Run Composer Install run: | - docker compose run --rm frankenphp composer install --no-interaction + docker compose run --rm "$PHP_SERVICE" composer install --no-interaction - name: Run Doctrine Migrations run: | - docker compose run --rm frankenphp bin/console doctrine:migrations:migrate --no-interaction + docker compose run --rm "$PHP_SERVICE" bin/console doctrine:migrations:migrate --no-interaction - name: Load fixtures run: | - docker compose run --rm frankenphp composer fixtures + docker compose run --rm "$PHP_SERVICE" composer fixtures - name: Check out the pull request run: | git checkout ${{ github.event.pull_request.head.sha }} + - name: Resolve the PHP service name + run: | + # This job straddles two revisions of docker-compose.yml: one + # of them may still call the PHP service phpfpm while the + # other calls it frankenphp. + if docker compose config --services | grep -qx frankenphp; then + echo "PHP_SERVICE=frankenphp" >> "$GITHUB_ENV" + else + echo "PHP_SERVICE=phpfpm" >> "$GITHUB_ENV" + fi + - name: Run Composer Install run: | - docker compose run --rm frankenphp composer install --no-interaction + docker compose run --rm "$PHP_SERVICE" composer install --no-interaction - name: Run Doctrine Migrations on the populated database run: | - docker compose run --rm frankenphp bin/console doctrine:migrations:migrate --no-interaction + docker compose run --rm "$PHP_SERVICE" bin/console doctrine:migrations:migrate --no-interaction From 5bc834f8683e3cdebdc506144751225f4d715bc9 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 13:52:35 +0200 Subject: [PATCH 05/14] feat: serve Prometheus metrics and align logging with the fpm stack Changes - Expose Caddy's Prometheus endpoint at `/metrics`, behind the `ITKMetricsAuth` middleware `/cron-metrics` used - Log requests as JSON from Caddy and keep PHP's `error_log` on `${PHP_LOGS}`, where php-fpm sent it - Mirror the itkdev/php8.5-fpm ini templates in `.docker/php.ini` using the image's own `PHP_*` variable names, defaulted in the Dockerfile - Trust `private_ranges` instead of `172.16.0.0/16` Why nginx exported no metrics at all: `stub_status` is compiled into the image but the template never enabled it, php-fpm's `pm.status_path` was never routed, and the supercronic behind `/cron-metrics` only starts when `/app/crontab` exists, which this project has no. Caddy has a real exporter, so the endpoint finally has something behind it. `172.16.0.0/16` covers neither the `frontend` network (172.18/16) nor the client (172.22/16), so `set_real_ip_from` never matched and real-IP resolution silently did nothing. `private_ranges` is what a `/12` in the template would have meant. The ini file previously hardcoded values the fpm image derives from environment variables, and invented two variable names. Mirroring the image's templates keeps the same overrides working. --- .docker/Caddyfile | 46 +++++++++++++++++----- .docker/php.ini | 63 +++++++++++++++++++----------- CHANGELOG.md | 9 +++++ Dockerfile | 24 ++++++++++++ README.md | 34 ++++++++++++++++ docker-compose.override.yml | 14 +++---- docker-compose.server.override.yml | 16 ++++---- 7 files changed, 158 insertions(+), 48 deletions(-) diff --git a/.docker/Caddyfile b/.docker/Caddyfile index 22cdac30..b53184aa 100644 --- a/.docker/Caddyfile +++ b/.docker/Caddyfile @@ -8,9 +8,18 @@ auto_https off skip_install_trust + # Request metrics, exposed at /metrics below. + metrics + servers { - # set_real_ip_from / real_ip_recursive / real_ip_header X-Forwarded-For - trusted_proxies static 172.16.0.0/16 192.168.39.0/24 + # set_real_ip_from / real_ip_recursive / real_ip_header X-Forwarded-For. + # + # private_ranges, not the template's 172.16.0.0/16: compose puts the + # frontend network on 172.18.0.0/16 and the client on 172.22.0.0/16, + # neither of which that /16 covers, so real-IP resolution never + # happened. private_ranges is 10/8, 172.16/12, 192.168/16 and + # localhost, which is what a /12 in the template would have meant. + trusted_proxies static private_ranges client_ip_headers X-Forwarded-For } @@ -26,9 +35,18 @@ # gzip on encode zstd br gzip - # error_log /dev/stderr, access_log /dev/stdout + # access_log /dev/stdout main + # + # JSON rather than nginx's `main` layout. Every field that format carried is + # here — client_ip, user_id, ts, method/uri/proto, status, size, and the + # Referer, User-Agent and X-Forwarded-For headers — plus duration, which + # nginx did not log. Reproducing the text layout byte for byte needs the + # transform encoder, which is not in the published image: this build has + # console, json, append, filter and journald only. It matches supercronic, + # which the fpm image already runs with -json. log { output stdout + format json } # client_max_body_size @@ -36,12 +54,22 @@ max_size {$PHP_MAX_BODY_SIZE:5MB} } - # Proxy to supercronic metrics. The upstream is a sidecar this project does - # not run yet, exactly as with nginx, where NGINX_CRON_METRICS pointed at a - # port nothing listened on. - handle /cron-metrics { - rewrite * /metrics - reverse_proxy {$CRON_METRICS_UPSTREAM:localhost:9746} + # Prometheus metrics, behind ITKMetricsAuth on its own Traefik router, the + # way /cron-metrics was. + # + # This replaces the nginx `location = /cron-metrics` proxy to + # supercronic. That proxy pointed at ${NGINX_CRON_METRICS}, and the fpm + # entrypoint only starts supercronic when /app/crontab exists — this + # project has no crontab, so nothing ever listened and the route answered + # 502. nginx itself exported nothing: stub_status is compiled into the + # image but the template never enabled it. + # + # Caddy does export, so the endpoint finally has something behind it: + # request counts, durations and sizes by code, method and handler, requests + # in flight, plus Go runtime and process metrics. A supercronic sidecar, if + # one is ever added, needs a route of its own. + handle /metrics { + metrics } # Protect files and directories from prying eyes. diff --git a/.docker/php.ini b/.docker/php.ini index 038050b5..f5dd043b 100644 --- a/.docker/php.ini +++ b/.docker/php.ini @@ -1,36 +1,53 @@ ; PHP settings for the FrankenPHP POC, mounted into ; /usr/local/etc/php/conf.d/. ; -; The itkdev/php8.4-fpm image turns a set of PHP_* environment variables into -; ini settings and ships a tuned baseline on top of php.ini-production. The -; published FrankenPHP image does neither, so both are reproduced here. Every -; variable is given a value in the docker-compose.frankenphp.* files — an unset -; one expands to the empty string and PHP warns. +; A port of what itkdev/php8.5-fpm configures and the published FrankenPHP image +; does not: the env-driven ini templates `fpm/conf.d/90-php.ini`, +; `mods-available/opcache.ini` and `mods-available/xdebug.ini`, plus the error +; logging from `fpm/pool.d/zz-fpm-docker.conf`. The variable names are the fpm +; image's own, so the same overrides work; the Dockerfile defaults every one of +; them, because an unset variable expands to the empty string and PHP warns. -; Set as env on itkdev/php8.4-fpm. -memory_limit = ${PHP_MEMORY_LIMIT} +; fpm/conf.d/90-php.ini +realpath_cache_size = 4096k +realpath_cache_ttl = 600 +expose_php = Off max_execution_time = ${PHP_MAX_EXECUTION_TIME} +memory_limit = ${PHP_MEMORY_LIMIT} +post_max_size = ${PHP_POST_MAX_SIZE} +upload_max_filesize = ${PHP_UPLOAD_MAX_FILESIZE} +date.timezone = ${PHP_TIMEZONE} sendmail_path = ${PHP_SENDMAIL_PATH} +max_input_vars = ${PHP_MAX_INPUT_VARS} -; itkdev/php8.4-fpm baseline. The FrankenPHP image leaves these at the -; development defaults: UTC, errors to the response body, no error log. -date.timezone = Europe/Copenhagen +; Logging. php-fpm sent its error log, its slowlog and — through +; catch_workers_output — everything a worker wrote to stderr to ${PHP_LOGS}, +; which is /dev/stderr. There is no fpm master here to collect worker output, so +; PHP writes to the same place directly. +; +; php-fpm configured no access log at all: the nginx access log was the only +; per-request record. Caddy's access log replaces it, see .docker/Caddyfile. +error_log = ${PHP_LOGS} +log_errors = On display_errors = Off display_startup_errors = Off -log_errors = On error_reporting = E_ALL & ~E_DEPRECATED -expose_php = Off -opcache.memory_consumption = 64 -opcache.max_accelerated_files = 20000 -opcache.validate_timestamps = 1 -opcache.revalidate_freq = 0 - -realpath_cache_size = 4096k -realpath_cache_ttl = 600 +; mods-available/opcache.ini +opcache.enable = ${PHP_OPCACHE_ENABLED} +opcache.jit = ${PHP_OPCACHE_JIT} +opcache.memory_consumption = ${PHP_OPCACHE_MEMORY_CONSUMPTION} +opcache.max_accelerated_files = ${PHP_OPCACHE_MAX_ACCELERATED_FILES} +opcache.max_wasted_percentage = ${PHP_OPCACHE_MAX_WASTED_PERCENTAGE} +opcache.revalidate_freq = ${PHP_OPCACHE_REVALIDATE_FREQ} +opcache.validate_timestamps = ${PHP_OPCACHE_VALIDATE_TIMESTAMPS} +opcache.interned_strings_buffer = 16 +opcache.fast_shutdown = 1 +opcache.optimization_level = 0xFFFFFFEF -; PHP_XDEBUG_MODE and PHP_XDEBUG_WITH_REQUEST are what itkdev-docker-compose -; sets when starting with a debugger attached. +; mods-available/xdebug.ini xdebug.mode = ${PHP_XDEBUG_MODE} -xdebug.start_with_request = ${PHP_XDEBUG_WITH_REQUEST} -xdebug.client_host = host.docker.internal +xdebug.client_host = ${PHP_XDEBUG_CLIENT_HOST} +xdebug.start_with_request = ${PHP_XDEBUG_START_WITH_REQUEST} +xdebug.max_nesting_level = ${PHP_XDEBUG_MAX_NESTING_LEVEL} +xdebug.output_dir = ${PHP_XDEBUG_OUTPUT_DIR} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8659a78a..1d56e658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `itkdev/supervisor-php8.5` included - Point Taskfile, workflows, Woodpecker and the README at the `frankenphp` service + - Serve Prometheus metrics from Caddy at `/metrics`, behind the same + `ITKMetricsAuth` middleware `/cron-metrics` used. nginx exported nothing, + and the supercronic it proxied to never started without an `/app/crontab` + - Log requests as JSON from Caddy, carrying every field nginx's `log_format + main` had plus `duration`. The published image has no transform encoder, so + the text layout cannot be reproduced exactly + - Trust `private_ranges` rather than the template's `172.16.0.0/16`, which + covered neither the `frontend` network nor the client, so real-IP resolution + never happened - Worker mode stays off: `runtime/frankenphp-symfony` has no Symfony 8 release - [#96](https://github.com/itk-dev/devops_itksites/pull/96) Show the Service Agreements monthly price as Danish kroner, diff --git a/Dockerfile b/Dockerfile index 684a97c4..e67f5c8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,3 +23,27 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY --from=composer/composer:2-bin /composer /usr/bin/composer + +# The itkdev/php8.5-fpm image turns these into ini settings and defaults them on +# the image rather than in compose. `.docker/php.ini` reads the same names, so +# the same overrides work and no variable is ever unset. +ENV PHP_LOGS=/dev/stderr \ + PHP_TIMEZONE=Europe/Copenhagen \ + PHP_MEMORY_LIMIT=128M \ + PHP_MAX_EXECUTION_TIME=30 \ + PHP_MAX_INPUT_VARS=1000 \ + PHP_POST_MAX_SIZE=8M \ + PHP_UPLOAD_MAX_FILESIZE=2M \ + PHP_SENDMAIL_PATH="/usr/sbin/sendmail -S host.docker.internal -t -i" \ + PHP_OPCACHE_ENABLED=1 \ + PHP_OPCACHE_JIT=off \ + PHP_OPCACHE_MEMORY_CONSUMPTION=64 \ + PHP_OPCACHE_MAX_ACCELERATED_FILES=20000 \ + PHP_OPCACHE_MAX_WASTED_PERCENTAGE=10 \ + PHP_OPCACHE_REVALIDATE_FREQ=0 \ + PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 \ + PHP_XDEBUG_MODE=off \ + PHP_XDEBUG_CLIENT_HOST=host.docker.internal \ + PHP_XDEBUG_START_WITH_REQUEST=yes \ + PHP_XDEBUG_MAX_NESTING_LEVEL=256 \ + PHP_XDEBUG_OUTPUT_DIR=/app diff --git a/README.md b/README.md index 318c8f53..427fe2d9 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,40 @@ Coming from the phpfpm stack, remove the containers it left behind once: docker compose rm --stop --force phpfpm nginx ``` +#### Logging + +php-fpm sent its error log, its slowlog and everything a worker wrote to stderr +to `/dev/stderr`, and configured no access log at all – the nginx access log was +the only per-request record. `.docker/php.ini` keeps `error_log` pointed at +`${PHP_LOGS}` and Caddy's access log replaces nginx's. + +Caddy logs JSON rather than nginx's `log_format main` text. Every field that +format carried is in it – `client_ip`, `user_id`, `ts`, method, uri, proto, +`status`, `size` and the `Referer`, `User-Agent` and `X-Forwarded-For` headers – +plus `duration`, which nginx did not log. The text layout cannot be reproduced +byte for byte without the Caddy transform encoder, which the published image +does not carry: this build has `console`, `json`, `append`, `filter` and +`journald`. JSON also matches supercronic, which the fpm image already runs with +`-json`. + +#### Metrics + +`/metrics` serves Prometheus metrics from Caddy, behind the `ITKMetricsAuth@file` +middleware on its own Traefik router. + +This is where `/cron-metrics` used to point. That route proxied to supercronic +on `${NGINX_CRON_METRICS}`, and the fpm entrypoint only starts supercronic when +`/app/crontab` exists – this project has no crontab, so nothing ever listened +and the route answered `502`. nginx exported nothing itself: `stub_status` is +compiled into the image but the template never enabled it, and php-fpm's +`pm.status_path = /status` was never routed. + +Caddy does export, so the endpoint has something behind it: request counts, +durations and sizes by code, method and handler, requests in flight, and Go +runtime and process metrics. FrankenPHP's own thread metrics only appear in +worker mode, which is off. A supercronic sidecar, if one is added, needs a route +of its own. + ### OpenID Connect All users access is controlled by OpenID Connect. For local development you must diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 3d0ddb57..4dbe95ad 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -26,15 +26,14 @@ services: SERVER_NAME: ":8080" SERVER_ROOT: /app/public PHP_MAX_BODY_SIZE: 5MB - CRON_METRICS_UPSTREAM: ${COMPOSE_PROJECT_NAME:?}-frankenphp-1:9746 - # Read by .docker/php.ini. Same values the itkdev/php8.5-fpm image gets - # from its own env handling. + # Read by .docker/php.ini, which uses the itkdev/php8.5-fpm image's own + # variable names. Everything else is defaulted on the image, so only the + # values that differ from the fpm image are set here. PHP_MEMORY_LIMIT: 256M - PHP_MAX_EXECUTION_TIME: "30" # Depending on the setup, you may have to remove --read-envelope-from from msmtp (cf. https://marlam.de/msmtp/msmtp.html) or use SMTP to send mail PHP_SENDMAIL_PATH: /usr/bin/msmtp --host=mail --port=1025 --read-recipients --read-envelope-from PHP_XDEBUG_MODE: ${PHP_XDEBUG_MODE:-off} - PHP_XDEBUG_WITH_REQUEST: ${PHP_XDEBUG_WITH_REQUEST:-no} + PHP_XDEBUG_START_WITH_REQUEST: ${PHP_XDEBUG_WITH_REQUEST:-yes} DOCKER_HOST_DOMAIN: ${COMPOSE_DOMAIN:?} PHP_IDE_CONFIG: serverName=localhost depends_on: @@ -58,8 +57,9 @@ services: # HTTPS config - uncomment to enable redirect from :80 to :443 # - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.middlewares=redirect-to-https" # - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - # Cron-metrics protection. - - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " + # Metrics protection. Caddy's Prometheus endpoint, where nginx proxied + # /cron-metrics to a supercronic that this project never starts. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_DOMAIN:?}`) && PathPrefix(`/metrics`)" - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" # Detailed health check protection. /health/live and /health/ready stay # public; only /health/detail discloses internals. diff --git a/docker-compose.server.override.yml b/docker-compose.server.override.yml index 6d25d36a..4373d5e5 100644 --- a/docker-compose.server.override.yml +++ b/docker-compose.server.override.yml @@ -24,13 +24,9 @@ services: SERVER_NAME: ":8080" SERVER_ROOT: /app/public PHP_MAX_BODY_SIZE: 5MB - CRON_METRICS_UPSTREAM: ${COMPOSE_PROJECT_NAME:?}-frankenphp-1:9746 - # Read by .docker/php.ini. - PHP_MEMORY_LIMIT: 128M - PHP_MAX_EXECUTION_TIME: "30" - PHP_SENDMAIL_PATH: /usr/sbin/sendmail -t -i - PHP_XDEBUG_MODE: "off" - PHP_XDEBUG_WITH_REQUEST: "no" + # Read by .docker/php.ini, which uses the itkdev/php8.5-fpm image's own + # variable names. The image defaults match the fpm image's, so nothing + # needs restating here. depends_on: rabbit: condition: service_healthy @@ -52,8 +48,10 @@ services: - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`)" - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}.entrypoints=websecure" - # Cron-metrics protection. - - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`) && PathPrefix(`/cron-metrics`) " + # Metrics protection. Caddy's Prometheus endpoint, where nginx proxied + # /cron-metrics to a supercronic that this project never starts. + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.rule=Host(`${COMPOSE_SERVER_DOMAIN:?}`) && PathPrefix(`/metrics`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.entrypoints=websecure" - "traefik.http.routers.${COMPOSE_PROJECT_NAME:?}-metrics.middlewares=ITKMetricsAuth@file" # Detailed health check protection. /health/live and /health/ready stay # public; only /health/detail discloses internals. From aa1ad2fc0c0c9d3179aff0d3a00878fa7faaa62d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 14:14:50 +0200 Subject: [PATCH 06/14] docs: correct why worker mode is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit symfony/runtime has shipped FrankenPhpWorkerRunner since 7.4, selected automatically off the FRANKENPHP_WORKER=1 that FrankenPHP sets for a worker script. runtime/frankenphp-symfony is only for older Symfony, so its lack of a Symfony 8 release never blocked anything — dropping it was right, but redundancy was the reason, not incompatibility. What actually holds worker mode back is application state, not the runtime. --- .docker/Caddyfile | 11 +++++++++++ CHANGELOG.md | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.docker/Caddyfile b/.docker/Caddyfile index b53184aa..5077c4d0 100644 --- a/.docker/Caddyfile +++ b/.docker/Caddyfile @@ -98,6 +98,17 @@ respond @directPhp 404 # try_files $uri /index.php$is_args$args + # + # Uncommenting the worker line is all worker mode needs: FrankenPHP sets + # FRANKENPHP_WORKER=1, and symfony/runtime has picked its own + # FrankenPhpWorkerRunner off that since 7.4 — no PHP package, no + # APP_RUNTIME override. Symfony 8.1 adds FRANKENPHP_RESET_KERNEL=1, which + # clones the kernel between requests to mitigate state leaks at the cost + # of a boot per request. + # + # Off until the stateful services are dealt with: LeantimeService caches + # the Leantime user directory per instance, and the package/module + # factories clear their dedup buffers outside a finally. php_server { #worker /app/public/index.php } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d56e658..e944f604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Trust `private_ranges` rather than the template's `172.16.0.0/16`, which covered neither the `frontend` network nor the client, so real-IP resolution never happened - - Worker mode stays off: `runtime/frankenphp-symfony` has no Symfony 8 release + - Leave worker mode off for now, but not for want of a runtime: `symfony/runtime` + has shipped `FrankenPhpWorkerRunner` since 7.4, so enabling it is one line in + `.docker/Caddyfile` and needs no package. Three stateful services want + attention first - [#96](https://github.com/itk-dev/devops_itksites/pull/96) Show the Service Agreements monthly price as Danish kroner, `12.500,50 kr.`, on index and detail From 85976da0af6921529c68aadfc3b704a713c6a7a7 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 14:33:01 +0200 Subject: [PATCH 07/14] refactor: make the version factories stateless, reset LeantimeService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes - `PackageVersionFactory` and `ModuleVersionFactory` keep their deduplication buffers in locals threaded through the private helpers, not in properties - Key the version buffers on object identity rather than on the entity id, so they hold before Doctrine has assigned one - `LeantimeService` implements `ResetInterface`; autoconfigure tags it `kernel.reset` - Add tests for all three; the factories had none Why The contract advises statelessness over `ResetInterface` where it is possible, and for the factories it is: the buffers exist only to stand in for the repositories between `persist()` and `flush()` within one call, so their lifetime is exactly that call. Making them locals also fixes the reason they were flagged — they were cleared after `flush()` rather than in a `finally`, so a failing flush left entities from a closed EntityManager for the next call. That was a live bug in the messenger consumer, which already runs long. `LeantimeService` is the case the fallback is for. Its cache cannot become a local: `resolveUserName()` is called inside a loop over tickets, so dropping it would cost an API round trip per ticket. `reset()` restores the per-request lifetime `loadUsers()` already documents. The two `testAFailedFlushLeavesNothingForTheNextCall` tests fail against the previous implementations; the other thirteen pass either way and guard the deduplication behaviour, including a null-version quirk left deliberately intact. --- CHANGELOG.md | 11 +- src/Service/LeantimeService.php | 27 ++- src/Service/ModuleVersionFactory.php | 61 +++---- src/Service/PackageVersionFactory.php | 66 ++++---- tests/Service/LeantimeServiceResetTest.php | 81 +++++++++ tests/Service/ModuleVersionFactoryTest.php | 174 ++++++++++++++++++++ tests/Service/PackageVersionFactoryTest.php | 169 +++++++++++++++++++ 7 files changed, 524 insertions(+), 65 deletions(-) create mode 100644 tests/Service/LeantimeServiceResetTest.php create mode 100644 tests/Service/ModuleVersionFactoryTest.php create mode 100644 tests/Service/PackageVersionFactoryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e944f604..0e24ec06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 never happened - Leave worker mode off for now, but not for want of a runtime: `symfony/runtime` has shipped `FrankenPhpWorkerRunner` since 7.4, so enabling it is one line in - `.docker/Caddyfile` and needs no package. Three stateful services want - attention first + `.docker/Caddyfile` and needs no package + - Make `PackageVersionFactory` and `ModuleVersionFactory` stateless: their + deduplication buffers are locals rather than properties, so a failing flush + can no longer leave entities from a closed EntityManager for the next call. + This was a live bug in the messenger consumer, which is already long-running + - Implement `ResetInterface` on `LeantimeService`, whose memoised user + directory has to stay a cache — `resolveUserName()` runs in a loop over + tickets — but must not outlive the request + - Cover all three with tests: the factories had none - [#96](https://github.com/itk-dev/devops_itksites/pull/96) Show the Service Agreements monthly price as Danish kroner, `12.500,50 kr.`, on index and detail diff --git a/src/Service/LeantimeService.php b/src/Service/LeantimeService.php index ed1374a0..cdec2679 100644 --- a/src/Service/LeantimeService.php +++ b/src/Service/LeantimeService.php @@ -6,6 +6,7 @@ use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\Service\ResetInterface; /** * Minimal JSON-RPC 2.0 client for the Leantime API. @@ -16,7 +17,7 @@ * the x-api-key header; this class only assembles the JSON-RPC body and * unwraps responses. */ -class LeantimeService +class LeantimeService implements ResetInterface { private const string JSONRPC_VERSION = '2.0'; private const string API_PATH = '/api/jsonrpc/'; @@ -281,4 +282,28 @@ private function loadUsers(): void } } } + + /** + * Drop the memoised user directory. + * + * loadUsers() fetches the Leantime directory once and every later lookup + * reads these two maps. Under php-fpm the instance died with the request, + * so "once per service instance" also meant "once per request". In a + * long-running worker the instance outlives the request and the directory + * would never be refetched, leaving new users, renames and changed + * addresses invisible until the worker recycled. + * + * Unlike the package and module factories, this cache cannot simply become + * a local: resolveUserName() is called inside a loop over tickets, so + * dropping it would cost one API round trip per ticket. Resetting it + * restores the per-request lifetime loadUsers() already documents. + * + * autoconfigure tags this kernel.reset, and services_resetter calls it + * between requests. + */ + public function reset(): void + { + $this->userNamesById = null; + $this->userIdsByEmail = null; + } } diff --git a/src/Service/ModuleVersionFactory.php b/src/Service/ModuleVersionFactory.php index 9772e20e..a591ee25 100644 --- a/src/Service/ModuleVersionFactory.php +++ b/src/Service/ModuleVersionFactory.php @@ -14,9 +14,6 @@ class ModuleVersionFactory { - private array $createdModules = []; - private array $createdModuleVersions = []; - public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ModuleRepository $moduleRepository, @@ -26,41 +23,42 @@ public function __construct( public function setModuleVersions(Installation $installation, object $installedModules): void { + // Locals, not properties: see PackageVersionFactory for why. Entities + // persisted below are not flushed until the end of the call, so these + // maps stand in for the repositories until then, and nothing outlives + // the method. + $createdModules = []; + $createdModuleVersions = []; + $moduleVersions = new ArrayCollection(); foreach ($installedModules as $name => $installed) { - $module = $this->getModule($name, $installed->package); + $module = $this->getModule($name, $installed->package, $createdModules); if (isset($installed->display_name)) { $module->setDisplayName($installed->display_name); } $module->setEnabled('Enabled' === $installed->status); - $moduleVersion = $this->getModuleVersion($module, $installed->version); + $moduleVersion = $this->getModuleVersion($module, $installed->version, $createdModuleVersions); $moduleVersions->add($moduleVersion); } $installation->setModuleVersions($moduleVersions); $this->entityManager->flush(); - $this->createdModules = []; - $this->createdModuleVersions = []; } - private function getModule(string $name, string $package): Module + /** + * @param array $createdModules modules persisted in this call but not yet flushed, keyed by name and package + */ + private function getModule(string $name, string $package, array &$createdModules): Module { + $key = $name."\0".$package; + $module = $this->moduleRepository->findOneBy([ 'name' => $name, 'package' => $package, - ]); - - if (null === $module) { - /** @var Module $createdModule */ - foreach ($this->createdModules as $createdModule) { - if ($name === $createdModule->getName() && $package === $createdModule->getPackage()) { - $module = $createdModule; - } - } - } + ]) ?? $createdModules[$key] ?? null; if (null === $module) { $module = new Module(); @@ -69,13 +67,16 @@ private function getModule(string $name, string $package): Module $module->setName($name); $module->setPackage($package); - $this->createdModules[] = $module; + $createdModules[$key] = $module; } return $module; } - private function getModuleVersion(Module $module, string|int|float|null $version): ModuleVersion + /** + * @param array $createdModuleVersions versions persisted in this call but not yet flushed, keyed by module identity and version + */ + private function getModuleVersion(Module $module, string|int|float|null $version, array &$createdModuleVersions): ModuleVersion { if (is_int($version) || is_float($version)) { $version = (string) $version; @@ -86,13 +87,15 @@ private function getModuleVersion(Module $module, string|int|float|null $version 'version' => $version, ]); - if (null === $moduleVersion) { - /** @var ModuleVersion $createdModuleVersion */ - foreach ($this->createdModuleVersions as $createdModuleVersion) { - if ($module->getId() === $createdModuleVersion->getModule()->getId() && $version === $createdModuleVersion->getVersion()) { - $moduleVersion = $createdModuleVersion; - } - } + // A null version is deliberately left out of the buffer. + // ModuleVersion::getVersion() reports 'Unknown' for null, so the scan + // this replaced never matched a null-versioned module either. Keeping + // that quirk keeps this change about state and nothing else; see the + // note in the pull request. + $key = null === $version ? null : spl_object_id($module)."\0".$version; + + if (null === $moduleVersion && null !== $key) { + $moduleVersion = $createdModuleVersions[$key] ?? null; } if (null === $moduleVersion) { @@ -102,7 +105,9 @@ private function getModuleVersion(Module $module, string|int|float|null $version $module->addModuleVersion($moduleVersion); $moduleVersion->setVersion($version); - $this->createdModuleVersions[] = $moduleVersion; + if (null !== $key) { + $createdModuleVersions[$key] = $moduleVersion; + } } return $moduleVersion; diff --git a/src/Service/PackageVersionFactory.php b/src/Service/PackageVersionFactory.php index dff3bee5..ebd3cba5 100644 --- a/src/Service/PackageVersionFactory.php +++ b/src/Service/PackageVersionFactory.php @@ -14,9 +14,6 @@ class PackageVersionFactory { - private array $createdPackages = []; - private array $createdPackageVersions = []; - public function __construct( private readonly EntityManagerInterface $entityManager, private readonly PackageRepository $packageRepository, @@ -26,11 +23,22 @@ public function __construct( public function setPackageVersions(Installation $installation, array $installedPackages): void { + // Entities are persisted below but not flushed until the end of this + // method, so the repositories cannot find them yet. These two maps + // stand in for the repositories for the rest of the call, keeping the + // same package from being created twice. + // + // They are locals, not properties. Nothing survives the method, so the + // service holds no state between calls — which is what makes it safe in + // a long-running process, the messenger consumer included. + $createdPackages = []; + $createdPackageVersions = []; + $packageVersions = new ArrayCollection(); foreach ($installedPackages as $installed) { [$vendor, $name] = explode('/', (string) $installed->name); - $package = $this->getPackage($vendor, $name); + $package = $this->getPackage($vendor, $name, $createdPackages); $package->setDescription($installed->description); if (isset($installed->warning)) { @@ -40,7 +48,7 @@ public function setPackageVersions(Installation $installation, array $installedP $package->setAbandoned($installed->abandoned); } - $packageVersion = $this->getPackageVersion($package, $installed->version); + $packageVersion = $this->getPackageVersion($package, $installed->version, $createdPackageVersions); $installation->addPackageVersion($packageVersion); $packageVersion->setVersion($installed->version); @@ -50,9 +58,6 @@ public function setPackageVersions(Installation $installation, array $installedP if (isset($installed->{'latest-status'})) { $packageVersion->setLatestStatus($installed->{'latest-status'}); } - if (isset($installed->{'latest-status'})) { - $packageVersion->setLatestStatus($installed->{'latest-status'}); - } $packageVersions->add($packageVersion); } @@ -60,25 +65,19 @@ public function setPackageVersions(Installation $installation, array $installedP $installation->setPackageVersions($packageVersions); $this->entityManager->flush(); - $this->createdPackages = []; - $this->createdPackageVersions = []; } - private function getPackage(string $vendor, string $name): Package + /** + * @param array $createdPackages packages persisted in this call but not yet flushed, keyed by vendor and name + */ + private function getPackage(string $vendor, string $name, array &$createdPackages): Package { + $key = $vendor."\0".$name; + $package = $this->packageRepository->findOneBy([ 'vendor' => $vendor, 'name' => $name, - ]); - - if (null === $package) { - /** @var Package $createdPackage */ - foreach ($this->createdPackages as $createdPackage) { - if ($vendor === $createdPackage->getVendor() && $name === $createdPackage->getName()) { - $package = $createdPackage; - } - } - } + ]) ?? $createdPackages[$key] ?? null; if (null === $package) { $package = new Package(); @@ -87,27 +86,26 @@ private function getPackage(string $vendor, string $name): Package $package->setVendor($vendor); $package->setName($name); - $this->createdPackages[] = $package; + $createdPackages[$key] = $package; } return $package; } - private function getPackageVersion(Package $package, string $version): PackageVersion + /** + * @param array $createdPackageVersions versions persisted in this call but not yet flushed, keyed by package identity and version + */ + private function getPackageVersion(Package $package, string $version, array &$createdPackageVersions): PackageVersion { + // Keyed on object identity rather than on the id: within one call the + // package may have been created moments ago, and object identity holds + // whether or not Doctrine has assigned an id yet. + $key = spl_object_id($package)."\0".$version; + $packageVersion = $this->packageVersionRepository->findOneBy([ 'package' => $package, 'version' => $version, - ]); - - if (null === $packageVersion) { - /* @var PackageVersion $packageVersion */ - foreach ($this->createdPackageVersions as $createdPackageVersion) { - if ($package->getId() === $createdPackageVersion->getPackage()->getId() && $version === $createdPackageVersion->getVersion()) { - $packageVersion = $createdPackageVersion; - } - } - } + ]) ?? $createdPackageVersions[$key] ?? null; if (null === $packageVersion) { $packageVersion = new PackageVersion(); @@ -116,7 +114,7 @@ private function getPackageVersion(Package $package, string $version): PackageVe $package->addPackageVersion($packageVersion); $packageVersion->setVersion($version); - $this->createdPackageVersions[] = $packageVersion; + $createdPackageVersions[$key] = $packageVersion; } return $packageVersion; diff --git a/tests/Service/LeantimeServiceResetTest.php b/tests/Service/LeantimeServiceResetTest.php new file mode 100644 index 00000000..0a710e34 --- /dev/null +++ b/tests/Service/LeantimeServiceResetTest.php @@ -0,0 +1,81 @@ +assertInstanceOf( + ResetInterface::class, + new LeantimeService(new MockHttpClient()), + 'autoconfigure only tags kernel.reset when the service implements ResetInterface' + ); + } + + public function testTheDirectoryIsFetchedOncePerInstance(): void + { + $client = new MockHttpClient([self::directory(), self::directory()]); + $service = new LeantimeService($client); + + $this->assertSame(7, $service->findUserIdByEmail('someone@aarhus.dk')); + $this->assertSame(7, $service->findUserIdByEmail('someone@aarhus.dk')); + + $this->assertSame(1, $client->getRequestsCount(), 'the second lookup must come from the cache'); + } + + public function testResetForcesTheDirectoryToBeFetchedAgain(): void + { + $client = new MockHttpClient([self::directory(), self::directory()]); + $service = new LeantimeService($client); + + $service->findUserIdByEmail('someone@aarhus.dk'); + $service->reset(); + $service->findUserIdByEmail('someone@aarhus.dk'); + + $this->assertSame(2, $client->getRequestsCount(), 'without this a worker would never see directory changes'); + } + + public function testTheDirectoryIsRereadAfterReset(): void + { + $client = new MockHttpClient([ + self::directory(), + new JsonMockResponse(['result' => [ + ['id' => 9, 'firstname' => 'New', 'lastname' => 'Starter', 'email' => 'new.starter@aarhus.dk'], + ]]), + ]); + $service = new LeantimeService($client); + + $this->assertNull($service->findUserIdByEmail('new.starter@aarhus.dk'), 'not in the directory yet'); + + $service->reset(); + + $this->assertSame(9, $service->findUserIdByEmail('new.starter@aarhus.dk'), 'visible after the reset'); + } + + private static function directory(): JsonMockResponse + { + return new JsonMockResponse(['result' => [ + ['id' => 7, 'firstname' => 'Some', 'lastname' => 'One', 'email' => 'someone@aarhus.dk'], + ]]); + } +} diff --git a/tests/Service/ModuleVersionFactoryTest.php b/tests/Service/ModuleVersionFactoryTest.php new file mode 100644 index 00000000..c24ddfc7 --- /dev/null +++ b/tests/Service/ModuleVersionFactoryTest.php @@ -0,0 +1,174 @@ + */ + private array $persisted = []; + + protected function setUp(): void + { + $this->persisted = []; + } + + public function testRepeatedModuleIsCreatedOnce(): void + { + // Drupal delivers modules as an object keyed by machine name, so a + // repeat within one payload means the same key twice — which only the + // package differing can produce. + $this->factory()->setModuleVersions(new Installation(), (object) [ + 'views' => self::installed('drupal/views', '1.0.0'), + ]); + + $this->assertCount(1, $this->modules()); + $this->assertCount(1, $this->moduleVersions()); + } + + public function testSameModuleWithTwoVersionsCreatesTwoVersions(): void + { + $factory = $this->factory(); + + $factory->setModuleVersions(new Installation(), (object) [ + 'views' => self::installed('drupal/views', '1.0.0'), + ]); + $modulesAfterFirst = count($this->modules()); + + $factory->setModuleVersions(new Installation(), (object) [ + 'views' => self::installed('drupal/views', '2.0.0'), + ]); + + $this->assertSame(1, $modulesAfterFirst); + $this->assertCount(2, $this->moduleVersions(), 'distinct versions are distinct rows'); + } + + public function testTwoNewModulesSharingAVersionStringStayApart(): void + { + $this->factory()->setModuleVersions(new Installation(), (object) [ + 'views' => self::installed('drupal/views', '1.0.0'), + 'token' => self::installed('drupal/token', '1.0.0'), + ]); + + $this->assertCount(2, $this->modules()); + $this->assertCount(2, $this->moduleVersions(), 'a shared version string must not merge two modules'); + } + + public function testNothingCarriesOverBetweenCalls(): void + { + $factory = $this->factory(); + + $factory->setModuleVersions(new Installation(), (object) ['views' => self::installed('drupal/views', '1.0.0')]); + $factory->setModuleVersions(new Installation(), (object) ['views' => self::installed('drupal/views', '1.0.0')]); + + $modules = array_values($this->modules()); + $this->assertCount(2, $modules, 'each call starts from an empty buffer'); + $this->assertNotSame($modules[0], $modules[1]); + } + + public function testAFailedFlushLeavesNothingForTheNextCall(): void + { + $factory = $this->factory(throwOnFirstFlush: true); + + try { + $factory->setModuleVersions(new Installation(), (object) ['views' => self::installed('drupal/views', '1.0.0')]); + $this->fail('expected the failing flush to throw'); + } catch (\RuntimeException) { + // Expected. + } + + $this->persisted = []; + $factory->setModuleVersions(new Installation(), (object) ['views' => self::installed('drupal/views', '1.0.0')]); + + $this->assertCount(1, $this->modules(), 'the module is created afresh, not taken from a stale buffer'); + } + + /** + * ModuleVersion::getVersion() reports 'Unknown' for a null version, so the + * scan this replaced never matched a null-versioned module in the buffer and + * created a row per occurrence. That quirk is preserved deliberately — + * changing it would change which rows get written. + */ + public function testNullVersionsAreNotDeduplicated(): void + { + $this->factory()->setModuleVersions(new Installation(), (object) [ + 'views' => self::installed('drupal/views', null), + 'token' => self::installed('drupal/views', null), + ]); + + $this->assertCount(2, $this->moduleVersions(), 'documented pre-existing behaviour, not an endorsement'); + } + + private function factory(bool $throwOnFirstFlush = false): ModuleVersionFactory + { + $entityManager = $this->createStub(EntityManagerInterface::class); + $entityManager->method('persist')->willReturnCallback( + function (object $entity): void { + // Doctrine assigns the ULID during persist(), because + // UlidGenerator is a CUSTOM rather than a post-insert + // generator. The stub does the same, so entities here are in + // the state the factory actually meets them in. + if ($entity instanceof AbstractBaseEntity) { + $entity->setId(new Ulid()); + } + + $this->persisted[] = $entity; + } + ); + + $flushes = 0; + $entityManager->method('flush')->willReturnCallback( + function () use ($throwOnFirstFlush, &$flushes): void { + if ($throwOnFirstFlush && 0 === $flushes++) { + throw new \RuntimeException('flush failed'); + } + } + ); + + $moduleRepository = $this->createStub(ModuleRepository::class); + $moduleRepository->method('findOneBy')->willReturn(null); + $moduleVersionRepository = $this->createStub(ModuleVersionRepository::class); + $moduleVersionRepository->method('findOneBy')->willReturn(null); + + return new ModuleVersionFactory($entityManager, $moduleRepository, $moduleVersionRepository); + } + + private static function installed(string $package, string|int|float|null $version): object + { + return (object) [ + 'package' => $package, + 'version' => $version, + 'status' => 'Enabled', + ]; + } + + /** @return array */ + private function modules(): array + { + return array_filter($this->persisted, static fn (object $e): bool => $e instanceof Module); + } + + /** @return array */ + private function moduleVersions(): array + { + return array_filter($this->persisted, static fn (object $e): bool => $e instanceof ModuleVersion); + } +} diff --git a/tests/Service/PackageVersionFactoryTest.php b/tests/Service/PackageVersionFactoryTest.php new file mode 100644 index 00000000..54a476bb --- /dev/null +++ b/tests/Service/PackageVersionFactoryTest.php @@ -0,0 +1,169 @@ + */ + private array $persisted = []; + + protected function setUp(): void + { + $this->persisted = []; + } + + public function testRepeatedPackageIsCreatedOnce(): void + { + $this->factory()->setPackageVersions(new Installation(), [ + self::installed('acme/foo', '1.0.0'), + self::installed('acme/foo', '1.0.0'), + ]); + + $this->assertCount(1, $this->packages(), 'the same vendor/name twice must reuse one Package'); + $this->assertCount(1, $this->packageVersions()); + } + + public function testSamePackageWithTwoVersionsCreatesTwoVersions(): void + { + $this->factory()->setPackageVersions(new Installation(), [ + self::installed('acme/foo', '1.0.0'), + self::installed('acme/foo', '2.0.0'), + ]); + + $this->assertCount(1, $this->packages()); + $this->assertCount(2, $this->packageVersions(), 'distinct versions of one package are distinct rows'); + } + + /** + * Two brand-new packages sharing a version string must not collapse into one + * PackageVersion. Keying the buffer on object identity is what keeps them + * apart — neither package has an id yet, because nothing has been flushed. + */ + public function testTwoNewPackagesSharingAVersionStringStayApart(): void + { + $this->factory()->setPackageVersions(new Installation(), [ + self::installed('acme/foo', '1.0.0'), + self::installed('acme/bar', '1.0.0'), + ]); + + $this->assertCount(2, $this->packages()); + $this->assertCount(2, $this->packageVersions(), 'a shared version string must not merge two packages'); + } + + /** + * The point of making the service stateless: a second call cannot reuse the + * first call's entities, even on the same instance. + */ + public function testNothingCarriesOverBetweenCalls(): void + { + $factory = $this->factory(); + + $factory->setPackageVersions(new Installation(), [self::installed('acme/foo', '1.0.0')]); + $factory->setPackageVersions(new Installation(), [self::installed('acme/foo', '1.0.0')]); + + $packages = array_values($this->packages()); + $this->assertCount(2, $packages, 'each call starts from an empty buffer'); + $this->assertNotSame($packages[0], $packages[1]); + } + + /** + * The buffers used to be cleared after flush() rather than in a finally, so + * a failing flush left them populated for the next call — holding entities + * attached to an EntityManager that had since closed. Locals cannot do that. + */ + public function testAFailedFlushLeavesNothingForTheNextCall(): void + { + $factory = $this->factory(throwOnFirstFlush: true); + + try { + $factory->setPackageVersions(new Installation(), [self::installed('acme/foo', '1.0.0')]); + $this->fail('expected the failing flush to throw'); + } catch (\RuntimeException) { + // Expected. What matters is the state left behind. + } + + $this->persisted = []; + $factory->setPackageVersions(new Installation(), [self::installed('acme/foo', '1.0.0')]); + + $this->assertCount(1, $this->packages(), 'the package is created afresh, not taken from a stale buffer'); + } + + private function factory(bool $throwOnFirstFlush = false): PackageVersionFactory + { + $entityManager = $this->createStub(EntityManagerInterface::class); + $entityManager->method('persist')->willReturnCallback( + function (object $entity): void { + // Doctrine assigns the ULID during persist(), because + // UlidGenerator is a CUSTOM rather than a post-insert + // generator. The stub does the same, so entities here are in + // the state the factory actually meets them in. + if ($entity instanceof AbstractBaseEntity) { + $entity->setId(new Ulid()); + } + + $this->persisted[] = $entity; + } + ); + + $flushes = 0; + $entityManager->method('flush')->willReturnCallback( + function () use ($throwOnFirstFlush, &$flushes): void { + if ($throwOnFirstFlush && 0 === $flushes++) { + throw new \RuntimeException('flush failed'); + } + } + ); + + // Nothing is in the database, so every lookup misses and the factory is + // forced onto its in-call buffers — which is what we want to exercise. + $packageRepository = $this->createStub(PackageRepository::class); + $packageRepository->method('findOneBy')->willReturn(null); + $packageVersionRepository = $this->createStub(PackageVersionRepository::class); + $packageVersionRepository->method('findOneBy')->willReturn(null); + + return new PackageVersionFactory($entityManager, $packageRepository, $packageVersionRepository); + } + + private static function installed(string $name, string $version): object + { + return (object) [ + 'name' => $name, + 'version' => $version, + 'description' => 'A package.', + ]; + } + + /** @return array */ + private function packages(): array + { + return array_filter($this->persisted, static fn (object $e): bool => $e instanceof Package); + } + + /** @return array */ + private function packageVersions(): array + { + return array_filter($this->persisted, static fn (object $e): bool => $e instanceof PackageVersion); + } +} From c973f0e5c323f992e94ea60a98a560e0b68efb58 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 14:37:25 +0200 Subject: [PATCH 08/14] fix: clear the admin URL generator before reusing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes - Call `unsetAll()` before `setController()` in `DashboardController` and `SecurityContractCrudController` - Add `DashboardControllerTest`, and put `SecurityContractCrudController` into the admin smoke test's provider Why EasyAdmin registers `AdminUrlGenerator` as `shared: no`, so each injection point gets its own instance — but both consumers here are shared, so that instance lives as long as they do, which in a worker is longer than one request. It accumulates route parameters as it is used. `AppExtension` and `RepoAdvisoryService` already opened with `unsetAll()`; these two were the inconsistency, and inconsistency is what rots. Neither site was covered. The dashboard test asserts where the redirect lands rather than that it merely redirects, because the failure mode worth catching is silent: `unsetAll()` placed after `setController()` wipes the controller back out and produces a URL pointing somewhere else without anything throwing. Both new tests fail against that arrangement. --- CHANGELOG.md | 8 ++- src/Controller/Admin/DashboardController.php | 1 + .../Admin/SecurityContractCrudController.php | 1 + tests/Controller/Admin/AdminSmokeTest.php | 2 + .../Admin/DashboardControllerTest.php | 68 +++++++++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/Controller/Admin/DashboardControllerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e24ec06..ca529221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implement `ResetInterface` on `LeantimeService`, whose memoised user directory has to stay a cache — `resolveUserName()` runs in a loop over tickets — but must not outlive the request - - Cover all three with tests: the factories had none + - Call `unsetAll()` before `setController()` on the injected + `AdminUrlGenerator` in `DashboardController` and + `SecurityContractCrudController`, which `AppExtension` and + `RepoAdvisoryService` already did. The instance is held for as long as its + consumer, which in a worker outlives the request + - Cover all of it with tests: the factories had none, and neither the + dashboard nor the Security Contract CRUD was in the admin smoke test - [#96](https://github.com/itk-dev/devops_itksites/pull/96) Show the Service Agreements monthly price as Danish kroner, `12.500,50 kr.`, on index and detail diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index e1edae7f..f7ee4426 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -28,6 +28,7 @@ public function __construct( public function index(): Response { $d = $this->adminUrlGenerator + ->unsetAll() ->setController(ServerCrudController::class)->setAction(Crud::PAGE_INDEX) ->generateUrl(); diff --git a/src/Controller/Admin/SecurityContractCrudController.php b/src/Controller/Admin/SecurityContractCrudController.php index d0e0cbb4..4ee8ecf0 100644 --- a/src/Controller/Admin/SecurityContractCrudController.php +++ b/src/Controller/Admin/SecurityContractCrudController.php @@ -132,6 +132,7 @@ public function syncAll(): RedirectResponse return $this->redirect( $this->adminUrlGenerator + ->unsetAll() ->setController(static::class) ->setAction(Crud::PAGE_INDEX) ->generateUrl() diff --git a/tests/Controller/Admin/AdminSmokeTest.php b/tests/Controller/Admin/AdminSmokeTest.php index c41e2534..d2d5ef1d 100644 --- a/tests/Controller/Admin/AdminSmokeTest.php +++ b/tests/Controller/Admin/AdminSmokeTest.php @@ -17,6 +17,7 @@ use App\Controller\Admin\OIDCCrudController; use App\Controller\Admin\PackageCrudController; use App\Controller\Admin\PackageVersionCrudController; +use App\Controller\Admin\SecurityContractCrudController; use App\Controller\Admin\ServerCrudController; use App\Controller\Admin\ServiceCertificateCrudController; use App\Controller\Admin\SiteCrudController; @@ -68,6 +69,7 @@ public static function crudControllerProvider(): iterable yield 'OIDC' => [OIDCCrudController::class]; yield 'Package' => [PackageCrudController::class]; yield 'PackageVersion' => [PackageVersionCrudController::class]; + yield 'SecurityContract' => [SecurityContractCrudController::class]; yield 'Server' => [ServerCrudController::class]; yield 'ServiceCertificate' => [ServiceCertificateCrudController::class]; yield 'Site' => [SiteCrudController::class]; diff --git a/tests/Controller/Admin/DashboardControllerTest.php b/tests/Controller/Admin/DashboardControllerTest.php new file mode 100644 index 00000000..1537bc7e --- /dev/null +++ b/tests/Controller/Admin/DashboardControllerTest.php @@ -0,0 +1,68 @@ +get('doctrine')->getManager() + ->getRepository(User::class)->findOneBy([]); + $client->loginUser($user); + + $client->request('GET', '/admin'); + + $this->assertResponseRedirects(); + + // Ask EasyAdmin what that URL should be rather than hard-coding the + // slug, so this keeps working if the route path changes. + $expected = static::getContainer()->get(AdminUrlGenerator::class) + ->setController(ServerCrudController::class) + ->setAction(Crud::PAGE_INDEX) + ->generateUrl(); + + $location = (string) $client->getResponse()->headers->get('Location'); + $this->assertSame( + parse_url($expected, \PHP_URL_PATH), + parse_url($location, \PHP_URL_PATH), + 'the redirect must still land on the Server index' + ); + } + + public function testTheRedirectTargetLoads(): void + { + $client = static::createClient(); + + $user = static::getContainer()->get('doctrine')->getManager() + ->getRepository(User::class)->findOneBy([]); + $client->loginUser($user); + + $client->request('GET', '/admin'); + $client->followRedirect(); + + $this->assertResponseIsSuccessful(); + } +} From a2c02afc223ddef81ea2177bb7c3e94b69e1fe24 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 15:13:49 +0200 Subject: [PATCH 09/14] docs: document worker mode and the statelessness it requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker mode needs no package and no code change — symfony/runtime has shipped FrankenPhpWorkerRunner since 7.4 and the Caddyfile already reads {$FRANKENPHP_CONFIG} — so it is documented as an environment variable, with what it measured here and the caveats on those numbers. The statelessness rules go in claude.md as a section rather than a bullet, because messenger:consume is already long-running in production and the rules apply whether or not worker mode is on. Each rule points at the service in this codebase that follows it. --- CHANGELOG.md | 5 +++++ README.md | 39 +++++++++++++++++++++++++++++++++++++++ claude.md | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca529221..3098dbc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 consumer, which in a worker outlives the request - Cover all of it with tests: the factories had none, and neither the dashboard nor the Security Contract CRUD was in the admin smoke test + - Document worker mode and the statelessness it requires in `README.md` and + `claude.md`. It stays off: measured here it gives roughly 20% more requests + per second on `/admin` and half the median latency, but around 40% fewer on + `/health/live`, and the numbers come from a laptop sharing CPU with other + containers - [#96](https://github.com/itk-dev/devops_itksites/pull/96) Show the Service Agreements monthly price as Danish kroner, `12.500,50 kr.`, on index and detail diff --git a/README.md b/README.md index 427fe2d9..07f69392 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,45 @@ does not carry: this build has `console`, `json`, `append`, `filter` and `journald`. JSON also matches supercronic, which the fpm image already runs with `-json`. +#### Worker mode + +Worker mode is off. Turning it on needs no PHP package and no code change: +`symfony/runtime` has shipped `FrankenPhpWorkerRunner` since 7.4, FrankenPHP +sets `FRANKENPHP_WORKER=1` for a worker script, and `SymfonyRuntime::getRunner()` +switches on that. `.docker/Caddyfile` reads `{$FRANKENPHP_CONFIG}`, so the switch +is an environment variable on the `frankenphp` service: + +```yaml +environment: + FRANKENPHP_CONFIG: worker /app/public/index.php +``` + +Add a count – `worker /app/public/index.php 8` – to override the default, which +is twice the number of CPU cores. Keep `num_threads` × `memory_limit` below the +memory available to the container. + +What it bought here, measured on this project in `prod` with a warm OPcache: +about 20% more requests per second on `/admin` and half the median latency, +against about 40% *fewer* on `/health/live`. The trivial endpoint is worker +mode's worst case – there is no per-request work for the saved kernel boot to be +weighed against, and the runner's `gc_collect_cycles()` on every request is not +free. The numbers come from a laptop sharing CPU with other containers and +running the application over a bind mount, so treat them as a shape rather than +a figure, and measure again on a server before adopting. + +**Services must not carry request state.** Under php-fpm a service instance died +with the request; in a worker it does not, so anything a service remembers leaks +into the next request. Prefer keeping services stateless. Where state is +deliberate, implement `Symfony\Contracts\Service\ResetInterface` – +`autoconfigure` tags it `kernel.reset` and Symfony calls it between requests. +For an object you do not own, clear it at the call site, the way every +`AdminUrlGenerator` chain here opens with `unsetAll()`. + +`FRANKENPHP_RESET_KERNEL=1`, on Symfony 8.1 and later, clones the kernel between +requests instead. It hides this class of bug at the cost of a boot per request, +which is most of what worker mode is for – useful to compare against, not to +depend on. + #### Metrics `/metrics` serves Prometheus metrics from Caddy, behind the `ITKMetricsAuth@file` diff --git a/claude.md b/claude.md index 30ea1d19..3d4c516b 100644 --- a/claude.md +++ b/claude.md @@ -85,6 +85,44 @@ docker compose exec frankenphp composer queues docker compose run --rm node yarn install && docker compose run --rm node yarn build ``` +## Long-running processes + +The site is served by a single FrankenPHP container in place of phpfpm and +nginx. Worker mode is off but available — `symfony/runtime` has shipped +`FrankenPhpWorkerRunner` since 7.4, and `.docker/Caddyfile` reads +`{$FRANKENPHP_CONFIG}`, so it is an environment variable, not a code change: + +```yaml +FRANKENPHP_CONFIG: worker /app/public/index.php +``` + +`messenger:consume` is already long-running in production regardless, so the +rules below apply whether or not worker mode is on. + +**Writing a service that has to remember something:** + +1. Prefer statelessness. If the state only needs to live for one method call, + make it a local and thread it through the private helpers — see + `PackageVersionFactory`, whose deduplication buffers work this way. The + `ResetInterface` docblock advises this over the interface where possible. +2. Where the state is deliberate, implement + `Symfony\Contracts\Service\ResetInterface` and clear everything in + `reset()`. `autoconfigure` tags it `kernel.reset` with no manual tagging — + see `LeantimeService`, which caches the Leantime user directory because + `resolveUserName()` runs in a loop. +3. For an object you do not own, clear it where you use it. Every + `AdminUrlGenerator` chain in this codebase opens with `unsetAll()` for this + reason: EasyAdmin registers it `shared: no`, but the services holding it are + shared, so the instance outlives the request. + +Things that break a worker and have no place here: `exit()`/`die()`, writes to +superglobals, `__destruct()` on a shared service, and mutable `static` +properties. + +`FRANKENPHP_RESET_KERNEL=1` (Symfony 8.1+) clones the kernel between requests +and papers over all of this, at the cost of a boot per request. Treat it as a +measurement baseline, not a fix. + ## Quality Checks All commands run inside Docker containers: @@ -146,3 +184,6 @@ Pull requests run these checks: - Async processing uses Symfony Messenger with AMQP transport - Environment-specific config goes in `.env.local` (not committed) - API specs (`public/api-spec-v1.yaml` and `.json`) must be regenerated and committed when API changes +- Services must not carry request state. The web container and the messenger + consumer are both long-running, so anything a service remembers outlives the + request that put it there From a9cb37ff8439c0063b3fa1695c1643569680f119 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 15:20:20 +0200 Subject: [PATCH 10/14] ci: gate pull requests on a worker-mode state audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes - Add `igor-php/igor-php` as a dev dependency and register `IgorPhpBundle` in dev - Configure it in `igor.json`: project scope, dev environment, baseline file - Record the 33 existing findings in `igor-baseline.json`, each with a reason - Add `composer worker-state-check` and `worker-state-baseline`, and a `Worker state audit` job to the review workflow Why The statelessness rules the last few commits established are the kind that decay without enforcement, and they matter whether or not worker mode is ever switched on: `messenger:consume` is already long-running in production. igor-php audits every shared service in the compiled container rather than grepping for patterns, which is why it caught the `AdminUrlGenerator` mutations that reading `src/` for stateful properties had missed. Against that, roughly two thirds of its project findings are noise — mostly Doctrine entities returned from a repository, which it reads as shared services — so it is only usable behind a baseline. Vendor code is out of scope: it reported 341 findings there, none of them ours to fix. Every baseline entry carries a reason rather than the generated TODO, so the file documents why each is safe instead of just silencing it. Verified the gate is live: introducing a stateful property on a service fails the audit, and removing it passes. --- .github/workflows/pr.yaml | 21 +++++ CHANGELOG.md | 4 + README.md | 21 +++++ claude.md | 6 ++ composer.json | 7 ++ composer.lock | 52 +++++++++++- config/bundles.php | 1 + igor-baseline.json | 166 ++++++++++++++++++++++++++++++++++++++ igor.json | 13 +++ 9 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 igor-baseline.json create mode 100644 igor.json diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 682e4b4f..5e8040ff 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -18,6 +18,27 @@ jobs: docker compose run --rm frankenphp composer install --no-interaction docker compose run --rm frankenphp vendor/bin/phpstan analyse + worker-state: + runs-on: ubuntu-latest + name: Worker state audit + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Create docker network + run: docker network create frontend + + # igor-php audits every shared service in the compiled container for + # state that would leak between requests in a worker. It reads the + # service map IgorPhpBundle writes during cache:clear, and fails only + # on findings absent from igor-baseline.json — every entry in which + # carries a reason. Regenerate with `composer worker-state-baseline`. + - name: Audit shared services for worker-mode state leaks + run: | + docker compose run --rm frankenphp composer install --no-interaction + docker compose run --rm frankenphp bin/console cache:clear + docker compose run --rm frankenphp composer worker-state-check + phpunit: runs-on: ubuntu-latest name: PHP Unit tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 3098dbc9..87d81b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 consumer, which in a worker outlives the request - Cover all of it with tests: the factories had none, and neither the dashboard nor the Security Contract CRUD was in the admin smoke test + - Gate pull requests on `igor-php`, which audits every shared service in the + compiled container for state that would leak between requests. + `igor-baseline.json` records the 33 existing findings with a reason each, so + the job fails only on new ones; vendor code is out of scope - Document worker mode and the statelessness it requires in `README.md` and `claude.md`. It stays off: measured here it gives roughly 20% more requests per second on `/admin` and half the median latency, but around 40% fewer on diff --git a/README.md b/README.md index 07f69392..940ddf7c 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,27 @@ deliberate, implement `Symfony\Contracts\Service\ResetInterface` – For an object you do not own, clear it at the call site, the way every `AdminUrlGenerator` chain here opens with `unsetAll()`. +That rule is enforced. [igor-php](https://github.com/igor-php/igor-php) audits +every shared service in the compiled container for state that would leak between +requests, and runs on every pull request: + +```sh +docker compose exec frankenphp composer worker-state-check +``` + +Existing findings live in `igor-baseline.json`, so the job fails only on new +ones. Every entry there carries a reason – most are Doctrine entities returned +from a repository, which igor reads as shared services, and `AdminUrlGenerator` +chains it cannot see are already cleared by `unsetAll()`. Read the reasons before +adding to them; if a finding is genuine, fix it rather than baseline it. After a +deliberate change, regenerate with `composer worker-state-baseline` and write a +reason for each new entry. + +The audit needs the service map that `IgorPhpBundle` writes during +`cache:clear`, so run that first if the cache is cold. Vendor code is out of +scope (`ignore_vendors` in `igor.json`): it reported 341 findings there, none of +them ours to fix. + `FRANKENPHP_RESET_KERNEL=1`, on Symfony 8.1 and later, clones the kernel between requests instead. It hides this class of bug at the cost of a boot per request, which is most of what worker mode is for – useful to compare against, not to diff --git a/claude.md b/claude.md index 3d4c516b..ce96de24 100644 --- a/claude.md +++ b/claude.md @@ -119,6 +119,12 @@ Things that break a worker and have no place here: `exit()`/`die()`, writes to superglobals, `__destruct()` on a shared service, and mutable `static` properties. +`composer worker-state-check` audits this with igor-php and runs on every pull +request. `igor-baseline.json` holds the known findings, each with a reason, so +the job fails only on new ones — fix a genuine finding rather than baselining +it, and regenerate with `composer worker-state-baseline` only after a deliberate +change. + `FRANKENPHP_RESET_KERNEL=1` (Symfony 8.1+) clones the kernel between requests and papers over all of this, at the cost of a boot per request. Treat it as a measurement baseline, not a fix. diff --git a/composer.json b/composer.json index 2d9da4ed..2ae02691 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ "ergebnis/composer-normalize": "^2.23", "friendsofphp/php-cs-fixer": "^3.6", "hautelook/alice-bundle": "^2.14", + "igor-php/igor-php": "^0.9.5", "justinrainbow/json-schema": "^6.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1", @@ -136,6 +137,12 @@ "update-api-spec": [ "bin/console api:openapi:export --output=public/api-spec-v1.yaml --yaml --no-interaction", "bin/console api:openapi:export --output=public/api-spec-v1.json --no-interaction" + ], + "worker-state-baseline": [ + "vendor/bin/igor-php --generate-baseline ." + ], + "worker-state-check": [ + "vendor/bin/igor-php ." ] } } diff --git a/composer.lock b/composer.lock index e55b8582..b519fbe6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ccbb3b812101b1a66fcae6a49c4c95d0", + "content-hash": "e5d85af3be7ba8dc31cc1ec8200b36e0", "packages": [ { "name": "api-platform/core", @@ -10540,6 +10540,56 @@ }, "time": "2026-03-21T21:21:40+00:00" }, + { + "name": "igor-php/igor-php", + "version": "v0.9.5", + "source": { + "type": "git", + "url": "https://github.com/igor-php/igor-php.git", + "reference": "35673901813ca9f2092fe062cbfd274e7a3aba89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igor-php/igor-php/zipball/35673901813ca9f2092fe062cbfd274e7a3aba89", + "reference": "35673901813ca9f2092fe062cbfd274e7a3aba89", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "bin": [ + "bin/igor-php" + ], + "type": "library", + "autoload": { + "psr-4": { + "IgorPhp\\IgorBundle\\": "src/php/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin MARTINS", + "email": "kevin.martins@me.com" + } + ], + "description": "The faithful assistant for your FrankenPHP Workers.", + "keywords": [ + "frankenphp", + "linter", + "static-analysis", + "symfony", + "worker" + ], + "support": { + "issues": "https://github.com/igor-php/igor-php/issues", + "source": "https://github.com/igor-php/igor-php/tree/v0.9.5" + }, + "time": "2026-08-12T10:38:29+00:00" + }, { "name": "justinrainbow/json-schema", "version": "6.11.0", diff --git a/config/bundles.php b/config/bundles.php index 2f78f3cd..264fa38a 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -23,4 +23,5 @@ Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + IgorPhp\IgorBundle\IgorPhpBundle::class => ['dev' => true], ]; diff --git a/igor-baseline.json b/igor-baseline.json new file mode 100644 index 00000000..5d15c2e7 --- /dev/null +++ b/igor-baseline.json @@ -0,0 +1,166 @@ +{ + "files": { + "src/Command/PurgeCommand.php": [ + { + "message": "Mutation detected on an injected dependency ($this->detectionResultRepository). Risk of State Leak in a worker.", + "reason": "A repository method call, not a property assignment. Console commands also run in their own process, never inside a worker request." + } + ], + "src/Command/ReplayDetectionResultsCommand.php": [ + { + "message": "Mutation detected on an injected dependency ($this->entityManager). Risk of State Leak in a worker.", + "reason": "Sets a Doctrine connection middleware for the duration of a console command. Commands run in their own process, never inside a worker request." + } + ], + "src/Controller/Admin/DashboardController.php": [ + { + "message": "Mutation detected on an injected dependency ($this->adminUrlGenerator). Risk of State Leak in a worker.", + "reason": "The chain opens with unsetAll(), which clears the accumulated route parameters before anything is set. Igor does not recognise unsetAll() as a reset. DashboardControllerTest asserts where the redirect lands, so a misplaced unsetAll() fails the build." + }, + { + "message": "Mutation detected on an injected dependency ($this->adminUrlGenerator). Risk of State Leak in a worker.", + "reason": "The chain opens with unsetAll(), which clears the accumulated route parameters before anything is set. Igor does not recognise unsetAll() as a reset. DashboardControllerTest asserts where the redirect lands, so a misplaced unsetAll() fails the build." + } + ], + "src/Controller/Admin/SecurityContractCrudController.php": [ + { + "message": "Mutation detected on an injected dependency ($this->adminUrlGenerator). Risk of State Leak in a worker.", + "reason": "The chain opens with unsetAll(), which clears the accumulated route parameters before anything is set. Igor does not recognise unsetAll() as a reset." + }, + { + "message": "Mutation detected on an injected dependency ($this->adminUrlGenerator). Risk of State Leak in a worker.", + "reason": "The chain opens with unsetAll(), which clears the accumulated route parameters before anything is set. Igor does not recognise unsetAll() as a reset." + } + ], + "src/Doctrine/Functions/SemverNumeric.php": [ + { + "message": "Mutation of state 'versionExpression' in SemverNumeric::parse()", + "reason": "A Doctrine DQL AST function node, not a service. Doctrine builds one per query while parsing, so the assignment in parse() cannot outlive it." + } + ], + "src/Handler/DockerImageHandler.php": [ + { + "message": "Mutation detected on an injected dependency ($this->dockerImageTagFactory). Risk of State Leak in a worker.", + "reason": "Calls a command method on a factory whose name begins with \"set\", which Igor treats as a setter. setPackageVersions() and friends do work and return void; they assign nothing on the factory, which is stateless." + }, + { + "message": "Mutation detected on an injected dependency ($this->packageVersionFactory). Risk of State Leak in a worker.", + "reason": "Calls a command method on a factory whose name begins with \"set\", which Igor treats as a setter. setPackageVersions() and friends do work and return void; they assign nothing on the factory, which is stateless." + }, + { + "message": "Mutation detected on an injected dependency ($this->advisoryFactory). Risk of State Leak in a worker.", + "reason": "Calls a command method on a factory whose name begins with \"set\", which Igor treats as a setter. setPackageVersions() and friends do work and return void; they assign nothing on the factory, which is stateless." + }, + { + "message": "Mutation detected on an injected dependency ($this->moduleVersionFactory). Risk of State Leak in a worker.", + "reason": "Calls a command method on a factory whose name begins with \"set\", which Igor treats as a setter. setPackageVersions() and friends do work and return void; they assign nothing on the factory, which is stateless." + } + ], + "src/Handler/DrupalHandler.php": [ + { + "message": "Mutation detected on an injected dependency ($this->packageVersionFactory). Risk of State Leak in a worker.", + "reason": "Calls a command method on a factory whose name begins with \"set\", which Igor treats as a setter. setPackageVersions() and friends do work and return void; they assign nothing on the factory, which is stateless." + }, + { + "message": "Mutation detected on an injected dependency ($this->moduleVersionFactory). Risk of State Leak in a worker.", + "reason": "Calls a command method on a factory whose name begins with \"set\", which Igor treats as a setter. setPackageVersions() and friends do work and return void; they assign nothing on the factory, which is stateless." + } + ], + "src/Handler/GitHandler.php": [ + { + "message": "Mutation detected on an injected dependency ($this->gitCloneFactory). Risk of State Leak in a worker.", + "reason": "Calls a command method on a factory whose name begins with \"set\", which Igor treats as a setter. setPackageVersions() and friends do work and return void; they assign nothing on the factory, which is stateless." + } + ], + "src/Handler/SymfonyHandler.php": [ + { + "message": "Mutation detected on an injected dependency ($this->packageVersionFactory). Risk of State Leak in a worker.", + "reason": "Calls a command method on a factory whose name begins with \"set\", which Igor treats as a setter. setPackageVersions() and friends do work and return void; they assign nothing on the factory, which is stateless." + }, + { + "message": "Mutation detected on an injected dependency ($this->packageVersionFactory). Risk of State Leak in a worker.", + "reason": "Calls a command method on a factory whose name begins with \"set\", which Igor treats as a setter. setPackageVersions() and friends do work and return void; they assign nothing on the factory, which is stateless." + } + ], + "src/Repository/DetectionResultRepository.php": [ + { + "message": "Mutation detected on a local reference to a shared service ($em). Risk of State Leak in a worker.", + "reason": "EntityManager::remove() is ordinary Doctrine usage, not a mutation of the manager. Whether the unit of work is flushed is the caller's business." + } + ], + "src/Service/ModuleVersionFactory.php": [ + { + "message": "Mutation detected on a local reference to a shared service ($module). Risk of State Leak in a worker.", + "reason": "Mutations on Doctrine entities returned from a repository, which Igor reads as shared services because the repository is one. The entities are rows, not services. The factory itself holds no state: its deduplication buffers are locals, covered by ModuleVersionFactoryTest." + }, + { + "message": "Mutation detected on a local reference to a shared service ($module). Risk of State Leak in a worker.", + "reason": "Mutations on Doctrine entities returned from a repository, which Igor reads as shared services because the repository is one. The entities are rows, not services. The factory itself holds no state: its deduplication buffers are locals, covered by ModuleVersionFactoryTest." + } + ], + "src/Service/PackageVersionFactory.php": [ + { + "message": "Mutation detected on a local reference to a shared service ($package). Risk of State Leak in a worker.", + "reason": "Mutations on Doctrine entities returned from a repository, which Igor reads as shared services because the repository is one. The entities are rows, not services. The factory itself holds no state: its deduplication buffers are locals, covered by PackageVersionFactoryTest." + }, + { + "message": "Mutation detected on a local reference to a shared service ($package). Risk of State Leak in a worker.", + "reason": "Mutations on Doctrine entities returned from a repository, which Igor reads as shared services because the repository is one. The entities are rows, not services. The factory itself holds no state: its deduplication buffers are locals, covered by PackageVersionFactoryTest." + }, + { + "message": "Mutation detected on a local reference to a shared service ($package). Risk of State Leak in a worker.", + "reason": "Mutations on Doctrine entities returned from a repository, which Igor reads as shared services because the repository is one. The entities are rows, not services. The factory itself holds no state: its deduplication buffers are locals, covered by PackageVersionFactoryTest." + }, + { + "message": "Mutation detected on a local reference to a shared service ($packageVersion). Risk of State Leak in a worker.", + "reason": "Mutations on Doctrine entities returned from a repository, which Igor reads as shared services because the repository is one. The entities are rows, not services. The factory itself holds no state: its deduplication buffers are locals, covered by PackageVersionFactoryTest." + }, + { + "message": "Mutation detected on a local reference to a shared service ($packageVersion). Risk of State Leak in a worker.", + "reason": "Mutations on Doctrine entities returned from a repository, which Igor reads as shared services because the repository is one. The entities are rows, not services. The factory itself holds no state: its deduplication buffers are locals, covered by PackageVersionFactoryTest." + }, + { + "message": "Mutation detected on a local reference to a shared service ($packageVersion). Risk of State Leak in a worker.", + "reason": "Mutations on Doctrine entities returned from a repository, which Igor reads as shared services because the repository is one. The entities are rows, not services. The factory itself holds no state: its deduplication buffers are locals, covered by PackageVersionFactoryTest." + } + ], + "src/Service/RepoAdvisoryService.php": [ + { + "message": "Mutation detected on an injected dependency ($this->adminUrlGenerator). Risk of State Leak in a worker.", + "reason": "The chain opens with unsetAll(), which clears the accumulated route parameters before anything is set. Igor does not recognise unsetAll() as a reset." + }, + { + "message": "Mutation detected on an injected dependency ($this->adminUrlGenerator). Risk of State Leak in a worker.", + "reason": "The chain opens with unsetAll(), which clears the accumulated route parameters before anything is set. Igor does not recognise unsetAll() as a reset." + }, + { + "message": "Mutation detected on an injected dependency ($this->adminUrlGenerator). Risk of State Leak in a worker.", + "reason": "The chain opens with unsetAll(), which clears the accumulated route parameters before anything is set. Igor does not recognise unsetAll() as a reset." + } + ], + "src/Trait/ExportCrudControllerTrait.php": [ + { + "message": "Mutation of state 'filterFactory' in ExportCrudControllerTrait::setFilterFactory()", + "reason": "Setter injection via #[Required]. The container calls these once while building the service, not at request time." + }, + { + "message": "Mutation of state 'exporter' in ExportCrudControllerTrait::setExporter()", + "reason": "Setter injection via #[Required]. The container calls these once while building the service, not at request time." + } + ], + "src/Twig/AppExtension.php": [ + { + "message": "Mutation detected on an injected dependency ($this->adminUrlGenerator). Risk of State Leak in a worker.", + "reason": "The chain opens with unsetAll(), which clears the accumulated route parameters before anything is set. Igor does not recognise unsetAll() as a reset." + }, + { + "message": "Mutation detected on an injected dependency ($this->adminUrlGenerator). Risk of State Leak in a worker.", + "reason": "The chain opens with unsetAll(), which clears the accumulated route parameters before anything is set. Igor does not recognise unsetAll() as a reset." + }, + { + "message": "Mutation detected on an injected dependency ($this->adminUrlGenerator). Risk of State Leak in a worker.", + "reason": "The chain opens with unsetAll(), which clears the accumulated route parameters before anything is set. Igor does not recognise unsetAll() as a reset." + } + ] + } +} diff --git a/igor.json b/igor.json new file mode 100644 index 00000000..5b58ed98 --- /dev/null +++ b/igor.json @@ -0,0 +1,13 @@ +{ + "exclude": ["migrations", "tests", "var"], + "safe_namespaces": [ + "Symfony\\", + "Doctrine\\", + "Psr\\", + "IgorPhp\\IgorBundle\\" + ], + "ignore_vendors": true, + "console_path": "bin/console", + "env": "dev", + "baseline": "igor-baseline.json" +} From c7da041574d74244700968e1a1a7e8f96d8de20f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 16:29:42 +0200 Subject: [PATCH 11/14] build: split the image into dev and prod stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes - Turn the Dockerfile into `base` → `dev` → `prod`; the override files pick a target, and a bare `docker build .` gets `prod` - `prod` drops Xdebug and sets `opcache.validate_timestamps=0` - Move the Xdebug ini to `.docker/php-dev.ini`, mounted only in development Why One image served both environments, so production loaded a debugger it never used and OPcache stat-ed every file on every request — `validate_timestamps=1` with `revalidate_freq=0` means check every time, which is the opposite of what that pair is usually meant to express. Turning timestamp validation off makes a code change need a new container. Both deployment paths already give it one: staging runs `up -d --force-recreate`, the release playbook brings the stack up again, and a fresh container starts with an empty OPcache, so it compiles what is on disk. The Xdebug ini moves rather than staying inert in production, so no file mentions settings whose extension is absent. Verified per stage: dev has the extension with timestamps validated, prod has neither, and coverage still collects in dev — `XDEBUG_MODE=coverage` reaches Xdebug even though `ini_get` reports the ini value, which the CI job depends on. --- .docker/php-dev.ini | 15 ++++++++++ .docker/php.ini | 12 ++------ Dockerfile | 44 ++++++++++++++++++++++-------- README.md | 20 +++++++++++++- docker-compose.override.yml | 6 +++- docker-compose.server.override.yml | 5 +++- 6 files changed, 79 insertions(+), 23 deletions(-) create mode 100644 .docker/php-dev.ini diff --git a/.docker/php-dev.ini b/.docker/php-dev.ini new file mode 100644 index 00000000..77fc5a23 --- /dev/null +++ b/.docker/php-dev.ini @@ -0,0 +1,15 @@ +; Xdebug settings, mounted only by docker-compose.override.yml. +; +; The extension is installed in the Dockerfile's `dev` stage and absent from +; `prod`, so neither this file nor the variables it reads reach a server. +; +; A port of mods-available/xdebug.ini from itkdev/php8.5-fpm, keeping its +; variable names: PHP_XDEBUG_MODE and PHP_XDEBUG_START_WITH_REQUEST are what +; itkdev-docker-compose sets when starting with a debugger attached. Xdebug also +; reads the XDEBUG_MODE environment variable directly, and that takes precedence +; — which is how CI turns on coverage without touching this file. +xdebug.mode = ${PHP_XDEBUG_MODE} +xdebug.client_host = ${PHP_XDEBUG_CLIENT_HOST} +xdebug.start_with_request = ${PHP_XDEBUG_START_WITH_REQUEST} +xdebug.max_nesting_level = ${PHP_XDEBUG_MAX_NESTING_LEVEL} +xdebug.output_dir = ${PHP_XDEBUG_OUTPUT_DIR} diff --git a/.docker/php.ini b/.docker/php.ini index f5dd043b..77ef19dc 100644 --- a/.docker/php.ini +++ b/.docker/php.ini @@ -3,8 +3,9 @@ ; ; A port of what itkdev/php8.5-fpm configures and the published FrankenPHP image ; does not: the env-driven ini templates `fpm/conf.d/90-php.ini`, -; `mods-available/opcache.ini` and `mods-available/xdebug.ini`, plus the error -; logging from `fpm/pool.d/zz-fpm-docker.conf`. The variable names are the fpm +; and `mods-available/opcache.ini`, plus the error +; logging from `fpm/pool.d/zz-fpm-docker.conf`. Xdebug lives in php-dev.ini, +; which production does not mount. The variable names are the fpm ; image's own, so the same overrides work; the Dockerfile defaults every one of ; them, because an unset variable expands to the empty string and PHP warns. @@ -44,10 +45,3 @@ opcache.validate_timestamps = ${PHP_OPCACHE_VALIDATE_TIMESTAMPS} opcache.interned_strings_buffer = 16 opcache.fast_shutdown = 1 opcache.optimization_level = 0xFFFFFFEF - -; mods-available/xdebug.ini -xdebug.mode = ${PHP_XDEBUG_MODE} -xdebug.client_host = ${PHP_XDEBUG_CLIENT_HOST} -xdebug.start_with_request = ${PHP_XDEBUG_START_WITH_REQUEST} -xdebug.max_nesting_level = ${PHP_XDEBUG_MAX_NESTING_LEVEL} -xdebug.output_dir = ${PHP_XDEBUG_OUTPUT_DIR} diff --git a/Dockerfile b/Dockerfile index e67f5c8c..64d27664 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,24 @@ -# FrankenPHP POC. +# FrankenPHP image for this application, in two flavours. # # The published dunglas/frankenphp images are deliberately minimal, so the # extensions this application cannot boot without are added on top. Everything -# else it needs — ctype, iconv, dom, mbstring, opcache, … — is already in the -# base image. -# Pinned to PHP 8.5 to match itkdev/php8.5-fpm and itkdev/supervisor-php8.5: -# the messenger worker and the web container share vendor/ and var/cache over -# the same bind mount, so they have to agree on the PHP version. -FROM dunglas/frankenphp:1.12-php8.5 +# else it needs — ctype, iconv, dom, mbstring, opcache, … — is already there. +# +# Pinned to PHP 8.5 to match itkdev/php8.5-fpm and itkdev/supervisor-php8.5: the +# messenger worker and the web container share vendor/ and var/cache over the +# same bind mount, so they have to agree on the PHP version. +# +# Pick a stage explicitly. Compose does, through `target:` in the two override +# files; a bare `docker build .` gets `prod`, the last stage, which is the safer +# of the two to end up with by accident. +FROM dunglas/frankenphp:1.12-php8.5 AS base RUN install-php-extensions \ pdo_mysql \ amqp \ intl \ gd \ - zip \ - xdebug + zip # msmtp keeps sendmail_path working the way it does in itkdev/php8.5-fpm. RUN apt-get update \ @@ -41,9 +44,28 @@ ENV PHP_LOGS=/dev/stderr \ PHP_OPCACHE_MAX_ACCELERATED_FILES=20000 \ PHP_OPCACHE_MAX_WASTED_PERCENTAGE=10 \ PHP_OPCACHE_REVALIDATE_FREQ=0 \ - PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 \ - PHP_XDEBUG_MODE=off \ + PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 + +# Development: Xdebug, and OPcache rechecking files so an edit takes effect. +FROM base AS dev + +RUN install-php-extensions xdebug + +# Read by .docker/php-dev.ini, which only development mounts. +ENV PHP_XDEBUG_MODE=off \ PHP_XDEBUG_CLIENT_HOST=host.docker.internal \ PHP_XDEBUG_START_WITH_REQUEST=yes \ PHP_XDEBUG_MAX_NESTING_LEVEL=256 \ PHP_XDEBUG_OUTPUT_DIR=/app + +# Production: no Xdebug, and OPcache trusting what it compiled. +# +# validate_timestamps=0 stops PHP stat-ing every file on every request, which +# validate_timestamps=1 with revalidate_freq=0 made it do. The cost is that a +# code change needs a new container — both deployment paths give it one, since +# staging runs `up -d --force-recreate` and the release playbook brings the stack +# up again, and a fresh container starts with an empty OPcache. It also makes +# PHP_OPCACHE_REVALIDATE_FREQ moot. +FROM base AS prod + +ENV PHP_OPCACHE_VALIDATE_TIMESTAMPS=0 diff --git a/README.md b/README.md index 940ddf7c..2cf61e4f 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,24 @@ against `frankenphp`. Traefik still terminates TLS. Caddy listens on plain HTTP on port 8080 and `auto_https` is off, so it neither requests nor serves certificates. -Two files carry the configuration that used to live on the phpfpm and nginx +The image is built in two flavours from one multi-stage `Dockerfile`, selected +by `target:` in the override files: + +| | `dev` | `prod` | +| --- | --- | --- | +| Xdebug | installed | **absent** | +| `opcache.validate_timestamps` | `1` | **`0`** | + +Development keeps Xdebug and lets OPcache recheck files so an edit takes effect. +Production has neither: the extension is not in the image, and OPcache trusts +what it compiled rather than stat-ing every file on every request, which +`validate_timestamps=1` with `revalidate_freq=0` made it do. The cost is that a +code change needs a new container, which both deployment paths already give it – +staging runs `up -d --force-recreate` and the release playbook brings the stack +up again, and a fresh container starts with an empty OPcache. A bare +`docker build .` resolves to `prod`, the last stage. + +Three files carry the configuration that used to live on the phpfpm and nginx images: - `.docker/Caddyfile` – a port of `.docker/nginx.conf` and @@ -131,6 +148,7 @@ images: - `.docker/php.ini` – the PHP settings the `itkdev/php8.5-fpm` image derives from its `PHP_*` environment variables, plus its tuned baseline. The compose files still set the same variables; the ini file interpolates them. +- `.docker/php-dev.ini` – the Xdebug half of that, mounted only in development. Coming from the phpfpm stack, remove the containers it left behind once: diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 4dbe95ad..a33da34c 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -15,7 +15,10 @@ services: - replaced-by-frankenphp frankenphp: - build: . + build: + context: . + # Xdebug, and OPcache rechecking files so an edit takes effect. + target: dev networks: - app - frontend @@ -45,6 +48,7 @@ services: - .:/app - ./.docker/Caddyfile:/etc/frankenphp/Caddyfile:ro - ./.docker/php.ini:/usr/local/etc/php/conf.d/zz-app.ini:ro + - ./.docker/php-dev.ini:/usr/local/etc/php/conf.d/zz-app-dev.ini:ro - caddy_data:/data - caddy_config:/config labels: diff --git a/docker-compose.server.override.yml b/docker-compose.server.override.yml index 4373d5e5..93af4151 100644 --- a/docker-compose.server.override.yml +++ b/docker-compose.server.override.yml @@ -12,7 +12,10 @@ services: - replaced-by-frankenphp frankenphp: - build: . + build: + context: . + # No Xdebug, and OPcache trusting what it compiled. + target: prod restart: unless-stopped networks: - app From 4bb654146df91d6f13c79019985c81c4f20bc0db Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 16:30:07 +0200 Subject: [PATCH 12/14] docs: note the dev/prod image split in claude.md and the changelog Missed from the previous commit: the script that wrote them asserted against README wording first and stopped before reaching these two. --- CHANGELOG.md | 4 ++++ claude.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d81b98..7322743c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 consumer, which in a worker outlives the request - Cover all of it with tests: the factories had none, and neither the dashboard nor the Security Contract CRUD was in the admin smoke test + - Split the image into `dev` and `prod` stages. Production drops Xdebug and + sets `opcache.validate_timestamps=0`, so it no longer loads a debugger it + never uses or stats every file on every request; Xdebug's ini moves to + `.docker/php-dev.ini`, which only development mounts - Gate pull requests on `igor-php`, which audits every shared service in the compiled container for state that would leak between requests. `igor-baseline.json` records the 33 existing findings with a reason each, so diff --git a/claude.md b/claude.md index ce96de24..785487dd 100644 --- a/claude.md +++ b/claude.md @@ -96,6 +96,10 @@ nginx. Worker mode is off but available — `symfony/runtime` has shipped FRANKENPHP_CONFIG: worker /app/public/index.php ``` +The `Dockerfile` is multi-stage: `target: dev` adds Xdebug and lets OPcache +recheck files, `target: prod` has neither. The override files pick the target, so +build through compose rather than a bare `docker build`. + `messenger:consume` is already long-running in production regardless, so the rules below apply whether or not worker mode is on. From b80049cdf623c49745e94530e766e665c65fe878 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 26 Aug 2026 16:39:41 +0200 Subject: [PATCH 13/14] feat: run the container as deploy and give it a health check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes - Create `deploy` and `runner` in the image, drop Caddy's `cap_net_bind_service`, hand it `/data/caddy` and `/config/caddy`, and end both stages with `USER deploy` - Make the id a `DEPLOY_UID` build argument, defaulting to 1042 - Mirror phpfpm's `user: ${COMPOSE_USER:-deploy}` on the local override - Add a health check on `/health/live` to both overrides - Reorder `task site:update` to install before waiting on health Why Root was the one regression against the setup this replaces: phpfpm ran as `${COMPOSE_USER:-deploy}`, and beyond the security footprint, a root container writing `var/` through a bind mount leaves root-owned files on the host. The id has to match whoever owns that checkout, and in devops_docker-images it depends on the base distro — consistently across 8.3, 8.4 and 8.5, the ubuntu tags give `deploy` 1000 and the alpine ones 1042. The servers run the alpine tags, so 1042 is the default; a build argument because the number belongs to the host account rather than to this image. Nothing needs a capability to bind 8080. `up --wait` previously only waited for the process to exist, since only mariadb had a check. Now it waits for the application to answer. That check calls into the application, so it cannot pass before dependencies are installed — hence starting, installing, then waiting, which is also the order that task always meant. Coming from the root-run container, `var/` needs handing over once: `docker compose run --rm --user root frankenphp chown -R deploy:deploy /app/var`. --- CHANGELOG.md | 7 ++++++ Dockerfile | 34 ++++++++++++++++++++++++++++++ README.md | 23 ++++++++++++++++++++ Taskfile.yml | 8 ++++++- claude.md | 5 +++++ docker-compose.override.yml | 16 ++++++++++++++ docker-compose.server.override.yml | 15 +++++++++++++ 7 files changed, 107 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7322743c..f7e029c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 consumer, which in a worker outlives the request - Cover all of it with tests: the factories had none, and neither the dashboard nor the Security Contract CRUD was in the admin smoke test + - Run the container as `deploy` rather than root, dropping Caddy's + `cap_net_bind_service` since port 8080 needs none. `DEPLOY_UID` is a build + argument defaulting to 1042, the id the alpine images the servers run give + `deploy` + - Give the container a health check on `/health/live`, so `up --wait` waits for + the application rather than the process, and reorder `task site:update` to + install before waiting - Split the image into `dev` and `prod` stages. Production drops Xdebug and sets `opcache.validate_timestamps=0`, so it no longer loads a debugger it never uses or stats every file on every request; Xdebug's ini moves to diff --git a/Dockerfile b/Dockerfile index 64d27664..c0740fa8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,36 @@ RUN apt-get update \ COPY --from=composer/composer:2-bin /composer /usr/bin/composer +# Run as a normal user rather than root. +# +# DEPLOY_UID has to match whoever owns the checkout this container bind-mounts, +# or the application cannot write var/. In devops_docker-images that id depends +# on the base distro, consistently across 8.3, 8.4 and 8.5: the ubuntu tags put +# deploy at 1000 (roles/ubuntu/tasks/main.yml) and the alpine ones at 1042 +# (roles/alpine/templates/Dockerfile.j2). The servers run the alpine tags — +# php8.5-fpm:alpine here, supervisor-php8.5:alpine for the messenger consumer — +# so 1042 is the id that owns /app there, and the default. +# +# Local development runs the ubuntu tag at 1000, which does not matter: Docker +# Desktop virtualises bind-mount ownership. CI selects runner through +# COMPOSE_USER, at the id GitHub's runner account uses; the alpine images have no +# runner user, but nothing runs CI against those. +ARG DEPLOY_UID=1042 +ARG DEPLOY_GID=1042 +ARG RUNNER_UID=1001 + +# Caddy listens on 8080, which needs no capability to bind, so the one the image +# ships with goes. Both users need Caddy's state directories: it writes its +# instance id and an autosaved config there even with auto_https off. +RUN groupadd --gid ${DEPLOY_GID} deploy \ + && useradd --uid ${DEPLOY_UID} --gid ${DEPLOY_GID} --create-home deploy \ + && groupadd --gid ${RUNNER_UID} runner \ + && useradd --uid ${RUNNER_UID} --gid ${RUNNER_UID} --create-home runner \ + && usermod --append --groups deploy runner \ + && setcap -r /usr/local/bin/frankenphp \ + && chown -R deploy:deploy /data/caddy /config/caddy \ + && chmod -R g+w /data/caddy /config/caddy + # The itkdev/php8.5-fpm image turns these into ini settings and defaults them on # the image rather than in compose. `.docker/php.ini` reads the same names, so # the same overrides work and no variable is ever unset. @@ -58,6 +88,8 @@ ENV PHP_XDEBUG_MODE=off \ PHP_XDEBUG_MAX_NESTING_LEVEL=256 \ PHP_XDEBUG_OUTPUT_DIR=/app +USER deploy + # Production: no Xdebug, and OPcache trusting what it compiled. # # validate_timestamps=0 stops PHP stat-ing every file on every request, which @@ -69,3 +101,5 @@ ENV PHP_XDEBUG_MODE=off \ FROM base AS prod ENV PHP_OPCACHE_VALIDATE_TIMESTAMPS=0 + +USER deploy diff --git a/README.md b/README.md index 2cf61e4f..d2daa83d 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,29 @@ Coming from the phpfpm stack, remove the containers it left behind once: docker compose rm --stop --force phpfpm nginx ``` +#### Container user and health check + +The container runs as `deploy`, not root, and Caddy's `cap_net_bind_service` is +removed – nothing needs it on port 8080. `DEPLOY_UID` is a build argument +because the id has to match whoever owns the checkout being bind-mounted, and in +`devops_docker-images` that depends on the base distro: the ubuntu tags put +`deploy` at 1000, the alpine ones at 1042. The servers run the alpine tags, so +1042 is the default. Local development runs the ubuntu tag at 1000, which does +not matter because Docker Desktop virtualises bind-mount ownership, and CI picks +`runner` through `COMPOSE_USER` as it always did. + +Coming from the root-run container, hand the files it wrote to `deploy` once: + +```sh +docker compose run --rm --user root frankenphp chown -R deploy:deploy /app/var +``` + +`/health/live` backs a container health check, so `docker compose up --wait` +waits for the application to answer rather than merely for the process to exist. +That check calls into the application, which cannot answer before its +dependencies are installed – which is why `task site:update` starts the stack, +installs, and only then waits. + #### Logging php-fpm sent its error log, its slowlog and everything a worker wrote to stderr diff --git a/Taskfile.yml b/Taskfile.yml index bac9ebcc..47a25b4b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -16,12 +16,18 @@ tasks: - task: compose vars: COMPOSE_ARGS: pull + # Start before installing, then wait: the frankenphp health check + # calls into the application, which cannot answer until its + # dependencies are in place. - task: compose vars: - COMPOSE_ARGS: up --detach --wait + COMPOSE_ARGS: up --detach - task: composer vars: COMPOSER_ARGS: install + - task: compose + vars: + COMPOSE_ARGS: up --detach --wait site:migrate: desc: Run database migrations diff --git a/claude.md b/claude.md index 785487dd..9833788e 100644 --- a/claude.md +++ b/claude.md @@ -100,6 +100,11 @@ The `Dockerfile` is multi-stage: `target: dev` adds Xdebug and lets OPcache recheck files, `target: prod` has neither. The override files pick the target, so build through compose rather than a bare `docker build`. +The container runs as `deploy`, not root. `DEPLOY_UID` is a build argument +because the id must match the owner of the bind-mounted checkout — 1042 on the +servers, which run the alpine tags. `/health/live` backs a container health +check, so `up --wait` waits for the application to answer. + `messenger:consume` is already long-running in production regardless, so the rules below apply whether or not worker mode is on. diff --git a/docker-compose.override.yml b/docker-compose.override.yml index a33da34c..496f7c84 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -19,6 +19,7 @@ services: context: . # Xdebug, and OPcache rechecking files so an edit takes effect. target: dev + user: ${COMPOSE_USER:-deploy} networks: - app - frontend @@ -42,6 +43,21 @@ services: depends_on: mariadb: condition: service_healthy + # /health/live is public and touches nothing, so it reports whether this + # container is serving without depending on the database or the broker. + healthcheck: + test: + [ + "CMD", + "curl", + "--fail", + "--silent", + "http://localhost:8080/health/live", + ] + start_period: 30s + interval: 10s + timeout: 5s + retries: 3 ports: - "8080" volumes: diff --git a/docker-compose.server.override.yml b/docker-compose.server.override.yml index 93af4151..ac302893 100644 --- a/docker-compose.server.override.yml +++ b/docker-compose.server.override.yml @@ -33,6 +33,21 @@ services: depends_on: rabbit: condition: service_healthy + # /health/live is public and touches nothing, so it reports whether this + # container is serving without depending on the database or the broker. + healthcheck: + test: + [ + "CMD", + "curl", + "--fail", + "--silent", + "http://localhost:8080/health/live", + ] + start_period: 30s + interval: 10s + timeout: 5s + retries: 3 volumes: - .:/app - ./.docker/Caddyfile:/etc/frankenphp/Caddyfile:ro From f2a4cfc04b83e339a705c60ca887024d8d52db1d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 27 Aug 2026 07:59:21 +0200 Subject: [PATCH 14/14] docs: measure FRANKENPHP_RESET_KERNEL instead of guessing at it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes - Record the three-way measurement on `/admin` in the Caddyfile, README and changelog - Say what cloning the kernel actually does, and stop implying the reset erases worker mode's benefit Why The comment claimed the reset costs "a boot per request", which is true — `AbstractKernel::__clone()` nulls the container and clears `booted`, so the next `handle()` runs `initializeBundles()` and instantiates the compiled container again. But it was asserted rather than measured, and the conclusion drawn from it was wrong. Measured in prod, 40 seconds at 20 concurrent on `/admin`: 1319 requests per second and a 9.0 ms median with no worker, 1494 and 4.3 ms with one, 1395 and 6.1 ms with one plus the reset. The reset keeps roughly half the gain and still beats no worker on both numbers, because the PHP runtime, OPcache and autoloader stay warm across requests even when the kernel does not. That makes it a reasonable first worker-mode configuration to deploy rather than only something to compare against. Also notes that a boot is not a recompile, which the old wording invited readers to assume. --- .docker/Caddyfile | 16 ++++++++++------ CHANGELOG.md | 9 +++++---- README.md | 43 +++++++++++++++++++++++++++++++------------ claude.md | 8 +++++--- 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/.docker/Caddyfile b/.docker/Caddyfile index 5077c4d0..e0a7f7f0 100644 --- a/.docker/Caddyfile +++ b/.docker/Caddyfile @@ -102,13 +102,17 @@ # Uncommenting the worker line is all worker mode needs: FrankenPHP sets # FRANKENPHP_WORKER=1, and symfony/runtime has picked its own # FrankenPhpWorkerRunner off that since 7.4 — no PHP package, no - # APP_RUNTIME override. Symfony 8.1 adds FRANKENPHP_RESET_KERNEL=1, which - # clones the kernel between requests to mitigate state leaks at the cost - # of a boot per request. + # APP_RUNTIME override. # - # Off until the stateful services are dealt with: LeantimeService caches - # the Leantime user directory per instance, and the package/module - # factories clear their dedup buffers outside a finally. + # Symfony 8.1 adds FRANKENPHP_RESET_KERNEL=1, which clones the kernel + # after each request. That does mean a kernel boot per request — + # AbstractKernel::__clone() nulls the container and clears `booted`, so + # the next handle() runs initializeBundles() and instantiates the + # compiled container again — but it keeps the PHP runtime, OPcache and + # autoloader warm, so it is not the same as no worker at all. Measured on + # /admin in prod: 1319 rps and a 9.0 ms median without a worker, 1494 and + # 4.3 ms with one, 1395 and 6.1 ms with one plus the reset. Roughly half + # the gain, and immune to state leaking between requests. php_server { #worker /app/public/index.php } diff --git a/CHANGELOG.md b/CHANGELOG.md index 958b5c26..1c1ded17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,10 +63,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `igor-baseline.json` records the 33 existing findings with a reason each, so the job fails only on new ones; vendor code is out of scope - Document worker mode and the statelessness it requires in `README.md` and - `claude.md`. It stays off: measured here it gives roughly 20% more requests - per second on `/admin` and half the median latency, but around 40% fewer on - `/health/live`, and the numbers come from a laptop sharing CPU with other - containers + `claude.md`. It stays off. Measured on `/admin` in prod: 1319 requests per + second without a worker, 1494 with one, 1395 with one plus + `FRANKENPHP_RESET_KERNEL=1` — so the reset keeps about half the gain rather + than erasing it. `/health/live` inverts the ranking, and the numbers come + from a laptop sharing CPU with other containers - [#96](https://github.com/itk-dev/devops_itksites/pull/96) Show the Service Agreements monthly price as Danish kroner, `12.500,50 kr.`, on index and detail diff --git a/README.md b/README.md index 99be63bf..f5fffe64 100644 --- a/README.md +++ b/README.md @@ -219,14 +219,25 @@ Add a count – `worker /app/public/index.php 8` – to override the default, wh is twice the number of CPU cores. Keep `num_threads` × `memory_limit` below the memory available to the container. -What it bought here, measured on this project in `prod` with a warm OPcache: -about 20% more requests per second on `/admin` and half the median latency, -against about 40% *fewer* on `/health/live`. The trivial endpoint is worker -mode's worst case – there is no per-request work for the saved kernel boot to be -weighed against, and the runner's `gc_collect_cycles()` on every request is not -free. The numbers come from a laptop sharing CPU with other containers and -running the application over a bind mount, so treat them as a shape rather than -a figure, and measure again on a server before adopting. +What it bought here, measured in `prod` with a warm OPcache, 40 seconds at 20 +concurrent on `/admin`: + +| | requests/sec | median | +| --- | --- | --- | +| no worker | 1319 | 9.0 ms | +| worker | 1494 | 4.3 ms | +| worker + `FRANKENPHP_RESET_KERNEL=1` | 1395 | 6.1 ms | + +On `/health/live` the ranking inverts – roughly 40% *fewer* requests per second +with a worker. That endpoint returns a constant, which is worker mode's worst +case: there is no per-request work for the saved kernel boot to be weighed +against, and the runner's `gc_collect_cycles()` on every request is not free. +Worth knowing, since the health endpoints are the polled ones. + +The numbers come from a laptop sharing CPU with other containers and running the +application over a bind mount, so treat them as a shape rather than a figure. +Short runs on that machine varied by more than tenfold; only 40-second runs were +reproducible. Measure again on a server before adopting. **Services must not carry request state.** Under php-fpm a service instance died with the request; in a worker it does not, so anything a service remembers leaks @@ -257,10 +268,18 @@ The audit needs the service map that `IgorPhpBundle` writes during scope (`ignore_vendors` in `igor.json`): it reported 341 findings there, none of them ours to fix. -`FRANKENPHP_RESET_KERNEL=1`, on Symfony 8.1 and later, clones the kernel between -requests instead. It hides this class of bug at the cost of a boot per request, -which is most of what worker mode is for – useful to compare against, not to -depend on. +`FRANKENPHP_RESET_KERNEL=1`, on Symfony 8.1 and later, clones the kernel after +each request instead, which makes this class of bug harmless. +`AbstractKernel::__clone()` nulls the container and clears `booted`, so the next +request runs `initializeBundles()` and instantiates the compiled container again +– a kernel boot, though not a recompile. It is not as expensive as it sounds: +the PHP runtime, OPcache and autoloader stay warm, and it kept about half the +worker-mode gain in the table above while still beating no worker on both +throughput and latency. + +That makes it a reasonable first configuration to deploy rather than only a +diagnostic – most of the latency win, immune to the leaks the audit below +guards against – with the reset turned off later once there is confidence. #### Metrics diff --git a/claude.md b/claude.md index 9833788e..647d5270 100644 --- a/claude.md +++ b/claude.md @@ -134,9 +134,11 @@ the job fails only on new ones — fix a genuine finding rather than baselining it, and regenerate with `composer worker-state-baseline` only after a deliberate change. -`FRANKENPHP_RESET_KERNEL=1` (Symfony 8.1+) clones the kernel between requests -and papers over all of this, at the cost of a boot per request. Treat it as a -measurement baseline, not a fix. +`FRANKENPHP_RESET_KERNEL=1` (Symfony 8.1+) clones the kernel after each request, +which makes all of this harmless. It costs a kernel boot per request but keeps +the PHP runtime warm, and measured on `/admin` it held about half the worker-mode +gain while still beating no worker — so it is a usable configuration, not just a +baseline. It is not a licence to write stateful services: the audit still runs. ## Quality Checks