From 1850c1472e171c1052301c80e6d82bb47781d4b5 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 19:07:16 +0200 Subject: [PATCH 01/14] feat: add user authentication (#2) Wires up the application's first persistent identity and the login flow it gates. Installs Doctrine ORM + migrations + Symfony Security + dev-only MakerBundle and DoctrineFixturesBundle; switches doctrine.yaml to MariaDB and sets DATABASE_URL to the in-stack mariadb service. App surface: App\Entity\User (email, hashed password, roles), UserRepository with PasswordUpgraderInterface, App\Security\UserManager service that owns persistence + hashing, App\Controller\SecurityController exposing /login and /logout (form_login + declarative logout in security.yaml), Twig login form under templates/security/, and Danish translation keys under security.login.*. Two console commands sit on UserManager: app:user:create and app:user:change-password. App\DataFixtures\UserFixtures seeds alice@example.test and bob@example.test (password `password`) for local dev. Tests: 32 cases, 70 assertions, 100% coverage. UserManager unit-tested via KernelTestCase + a schema-reset trait; UserRepository's upgradePassword covered for the happy path and the foreign-user rejection; commands exercised through CommandTester; SecurityController's full /login + /logout + failed-login flow exercised through WebTestCase. Tests share tests/Support/ResetsDatabaseSchemaTrait which drops + recreates the db_test schema from ORM metadata in setUp. Docs: README gains a "Creating the first user" subsection covering migration, fixture load, and the two console commands. CHANGELOG entry under [Unreleased] / Added references #2. Closes #2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env | 4 + CHANGELOG.md | 8 + README.md | 18 + composer.json | 6 + composer.lock | 4307 ++++++++++++----- config/bundles.php | 5 + config/packages/doctrine.yaml | 39 + config/packages/doctrine_migrations.yaml | 6 + config/packages/security.yaml | 45 + config/reference.php | 535 ++ config/routes/security.yaml | 3 + migrations/.gitignore | 0 migrations/Version20260611124347.php | 29 + phpunit.dist.xml | 2 + src/Command/UserChangePasswordCommand.php | 75 + src/Command/UserCreateCommand.php | 77 + src/Controller/SecurityController.php | 68 + src/DataFixtures/UserFixtures.php | 44 + src/Entity/.gitignore | 0 src/Entity/User.php | 109 + src/Repository/.gitignore | 0 src/Repository/UserRepository.php | 35 + src/Security/UserManager.php | 113 + symfony.lock | 70 + templates/security/login.html.twig | 49 + .../Command/UserChangePasswordCommandTest.php | 57 + tests/Command/UserCreateCommandTest.php | 57 + tests/Controller/SecurityControllerTest.php | 110 + tests/DataFixtures/UserFixturesTest.php | 41 + tests/Repository/UserRepositoryTest.php | 69 + tests/Security/UserManagerTest.php | 104 + tests/Support/ResetsDatabaseSchemaTrait.php | 34 + translations/messages.da.yaml | 9 + 33 files changed, 4990 insertions(+), 1138 deletions(-) create mode 100644 config/packages/doctrine.yaml create mode 100644 config/packages/doctrine_migrations.yaml create mode 100644 config/packages/security.yaml create mode 100644 config/routes/security.yaml create mode 100644 migrations/.gitignore create mode 100644 migrations/Version20260611124347.php create mode 100644 src/Command/UserChangePasswordCommand.php create mode 100644 src/Command/UserCreateCommand.php create mode 100644 src/Controller/SecurityController.php create mode 100644 src/DataFixtures/UserFixtures.php create mode 100644 src/Entity/.gitignore create mode 100644 src/Entity/User.php create mode 100644 src/Repository/.gitignore create mode 100644 src/Repository/UserRepository.php create mode 100644 src/Security/UserManager.php create mode 100644 templates/security/login.html.twig create mode 100644 tests/Command/UserChangePasswordCommandTest.php create mode 100644 tests/Command/UserCreateCommandTest.php create mode 100644 tests/Controller/SecurityControllerTest.php create mode 100644 tests/DataFixtures/UserFixturesTest.php create mode 100644 tests/Repository/UserRepositoryTest.php create mode 100644 tests/Security/UserManagerTest.php create mode 100644 tests/Support/ResetsDatabaseSchemaTrait.php diff --git a/.env b/.env index 3c692d1..38bbe52 100644 --- a/.env +++ b/.env @@ -34,3 +34,7 @@ BRAND_NAME="AI Bibliotek" BRAND_TAGLINE="del & hjemtag assistenter" BRAND_INITIALS="AI" ###< brand identity ### + +###> doctrine/doctrine-bundle ### +DATABASE_URL="mysql://db:db@mariadb:3306/db?serverVersion=10.11.16-MariaDB&charset=utf8mb4" +###< doctrine/doctrine-bundle ### diff --git a/CHANGELOG.md b/CHANGELOG.md index 30aa2a5..524b90c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PHPUnit test harness with 100% coverage gate enforced in CI via `rregeer/phpunit-coverage-check` ([#31](https://github.com/itk-dev/ai-lib/issues/31)). +- User authentication: `User` Doctrine entity (email, hashed password, + roles), `UserRepository` (with `PasswordUpgraderInterface`), the + `UserManager` service that hides persistence + hashing, form-login + firewall + `/login` + `/logout`, fixtures for two baseline users + (`alice@example.test`, `bob@example.test` — password `password`), + console commands `app:user:create` and `app:user:change-password`, + and end-to-end functional + unit tests + ([#2](https://github.com/itk-dev/ai-lib/issues/2)). ### Changed diff --git a/README.md b/README.md index ff1d30d..1bc9cb0 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,24 @@ task open The site is served through Traefik on a `*.local.itkdev.dk` domain (the exact URL is printed by the start task). +### Creating the first user + +```sh +# Apply the database schema +task console -- doctrine:migrations:migrate -n + +# Option A — load the local-dev fixtures (alice + bob, password `password`) +task console -- doctrine:fixtures:load -n + +# Option B — create a single user explicitly +task console -- app:user:create alice@example.test secret + +# Change an existing user's password +task console -- app:user:change-password alice@example.test newsecret +``` + +Then sign in at `/login`. + ## Testing Tests live under `tests/` (PSR-4 namespace `App\Tests\`) and run with diff --git a/composer.json b/composer.json index 79beba5..aa0ffec 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,9 @@ "php": ">=8.4", "ext-ctype": "*", "ext-iconv": "*", + "doctrine/doctrine-bundle": "^3.2", + "doctrine/doctrine-migrations-bundle": "^4.0", + "doctrine/orm": "^3.6", "symfony/asset": "^8.1", "symfony/asset-mapper": "^8.1", "symfony/console": "~8.1.0", @@ -14,6 +17,7 @@ "symfony/flex": "^2", "symfony/framework-bundle": "~8.1.0", "symfony/runtime": "~8.1.0", + "symfony/security-bundle": "~8.1.0", "symfony/stimulus-bundle": "^3.1", "symfony/translation": "~8.1.0", "symfony/twig-bundle": "~8.1.0", @@ -24,12 +28,14 @@ "twig/twig": "^2.12 || ^3.0" }, "require-dev": { + "doctrine/doctrine-fixtures-bundle": "^4.3", "ergebnis/composer-normalize": "^2.52", "friendsofphp/php-cs-fixer": "^3.95.5", "phpunit/phpunit": "^11.5", "rregeer/phpunit-coverage-check": "^0.3.1", "symfony/browser-kit": "~8.1.0", "symfony/css-selector": "~8.1.0", + "symfony/maker-bundle": "^1.67", "symfony/phpunit-bridge": "~8.1.0", "vincentlanglet/twig-cs-fixer": "^3.14" }, diff --git a/composer.lock b/composer.lock index abb6510..3bead4f 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": "5e5be02b1936d9ba94164fc47d576055", + "content-hash": "515575e1ba1d454616c991cd865c269a", "packages": [ { "name": "composer/semver", @@ -84,31 +84,35 @@ "time": "2025-08-20T19:15:30+00:00" }, { - "name": "psr/cache", - "version": "3.0.0", + "name": "doctrine/collections", + "version": "2.6.0", "source": { "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + "url": "https://github.com/doctrine/collections.git", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2", "shasum": "" }, "require": { - "php": ">=8.0.0" + "doctrine/deprecations": "^1", + "php": "^8.1", + "symfony/polyfill-php84": "^1.30" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-json": "*", + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" }, + "type": "library", "autoload": { "psr-4": { - "Psr\\Cache\\": "src/" + "Doctrine\\Common\\Collections\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -117,47 +121,94 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" } ], - "description": "Common interface for caching libraries", + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", "keywords": [ - "cache", - "psr", - "psr-6" + "array", + "collections", + "iterators", + "php" ], "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/2.6.0" }, - "time": "2021-02-03T23:26:27+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcollections", + "type": "tidelift" + } + ], + "time": "2026-01-15T10:01:58+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "doctrine/dbal", + "version": "4.4.3", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/doctrine/dbal.git", + "reference": "61e730f1658814821a85f2402c945f3883407dec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec", + "reference": "61e730f1658814821a85f2402c945f3883407dec", "shasum": "" }, "require": { - "php": ">=7.4.0" + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.2", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", + "symfony/cache": "^6.3.8|^7.0|^8.0", + "symfony/console": "^5.4|^6.3|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." }, + "type": "library", "autoload": { "psr-4": { - "Psr\\Container\\": "src/" + "Doctrine\\DBAL\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -166,101 +217,176 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/4.4.3" }, - "time": "2021-11-05T16:47:00+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2026-03-20T08:52:12+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "doctrine/deprecations", + "version": "1.1.6", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": "^7.1 || ^8.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", "autoload": { "psr-4": { - "Psr\\EventDispatcher\\": "src/" + "Doctrine\\Deprecations\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" - } - ], - "description": "Standard interfaces for event handling.", - "keywords": [ - "events", - "psr", - "psr-14" - ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "doctrine/doctrine-bundle", + "version": "3.2.4", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/doctrine/DoctrineBundle.git", + "reference": "75f1bf75d0ba099f23e7d43ebd804df5bec58c29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/75f1bf75d0ba099f23e7d43ebd804df5bec58c29", + "reference": "75f1bf75d0ba099f23e7d43ebd804df5bec58c29", "shasum": "" }, "require": { - "php": ">=8.0.0" + "doctrine/dbal": "^4.0", + "doctrine/deprecations": "^1.0", + "doctrine/persistence": "^4", + "doctrine/sql-formatter": "^1.0.1", + "php": "^8.4", + "symfony/cache": "^6.4 || ^7.0 || ^8.0", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/service-contracts": "^3" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } + "conflict": { + "doctrine/orm": "<3.0 || >=4.0", + "twig/twig": "<3.0.4" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "doctrine/orm": "^3.4.4", + "phpstan/phpstan": "^2.1.13", + "phpstan/phpstan-phpunit": "2.0.3", + "phpstan/phpstan-strict-rules": "^2", + "phpstan/phpstan-symfony": "^2.0.9", + "phpunit/phpunit": "^12.3.10", + "psr/log": "^3.0", + "symfony/doctrine-messenger": "^6.4 || ^7.0 || ^8.0", + "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/messenger": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^6.4 || ^7.0 || ^8.0", + "symfony/security-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/stopwatch": "^6.4 || ^7.0 || ^8.0", + "symfony/string": "^6.4 || ^7.0 || ^8.0", + "symfony/twig-bridge": "^6.4 || ^7.0 || ^8.0", + "symfony/validator": "^6.4 || ^7.0 || ^8.0", + "symfony/web-profiler-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0", + "twig/twig": "^3.21.1" + }, + "suggest": { + "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", + "ext-pdo": "*", + "symfony/web-profiler-bundle": "To use the data collector." }, + "type": "symfony-bundle", "autoload": { "psr-4": { - "Psr\\Log\\": "src" + "Doctrine\\Bundle\\DoctrineBundle\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -269,52 +395,96 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org/" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Symfony DoctrineBundle", + "homepage": "https://www.doctrine-project.org", "keywords": [ - "log", - "psr", - "psr-3" + "database", + "dbal", + "orm", + "persistence" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "issues": "https://github.com/doctrine/DoctrineBundle/issues", + "source": "https://github.com/doctrine/DoctrineBundle/tree/3.2.4" }, - "time": "2024-09-11T13:17:53+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-bundle", + "type": "tidelift" + } + ], + "time": "2026-06-09T19:11:55+00:00" }, { - "name": "symfony/asset", - "version": "v8.1.0", + "name": "doctrine/doctrine-migrations-bundle", + "version": "4.0.0", "source": { "type": "git", - "url": "https://github.com/symfony/asset.git", - "reference": "4bd4d143b7e53f40d45877df52eb2b18282bdac4" + "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", + "reference": "20505da78735744fb4a42a3bb9a416b345ad6f7c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset/zipball/4bd4d143b7e53f40d45877df52eb2b18282bdac4", - "reference": "4bd4d143b7e53f40d45877df52eb2b18282bdac4", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/20505da78735744fb4a42a3bb9a416b345ad6f7c", + "reference": "20505da78735744fb4a42a3bb9a416b345ad6f7c", "shasum": "" }, "require": { - "php": ">=8.4.1" + "doctrine/dbal": "^4", + "doctrine/doctrine-bundle": "^3", + "doctrine/migrations": "^3.2", + "php": "^8.4", + "psr/log": "^3", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/deprecation-contracts": "^3", + "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", + "symfony/http-foundation": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/service-contracts": "^3.0" }, "require-dev": { - "symfony/http-client": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0" + "composer/semver": "^3.0", + "doctrine/coding-standard": "^14", + "doctrine/orm": "^3", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpstan/phpstan-symfony": "^2", + "phpunit/phpunit": "^12.5", + "symfony/var-exporter": "^6.4 || ^7 || ^8" }, - "type": "library", + "type": "symfony-bundle", "autoload": { "psr-4": { - "Symfony\\Component\\Asset\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Doctrine\\Bundle\\MigrationsBundle\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -325,77 +495,73 @@ "name": "Fabien Potencier", "email": "fabien@symfony.com" }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org" + }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", - "homepage": "https://symfony.com", + "description": "Symfony DoctrineMigrationsBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "dbal", + "migrations", + "schema" + ], "support": { - "source": "https://github.com/symfony/asset/tree/v8.1.0" + "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/4.0.0" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://www.doctrine-project.org/sponsorship.html", "type": "custom" }, { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-migrations-bundle", "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2025-12-05T08:14:38+00:00" }, { - "name": "symfony/asset-mapper", - "version": "v8.1.0", + "name": "doctrine/event-manager", + "version": "2.1.1", "source": { "type": "git", - "url": "https://github.com/symfony/asset-mapper.git", - "reference": "74b1b7b7019c728cb1f8672b502260e683b6374e" + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/74b1b7b7019c728cb1f8672b502260e683b6374e", - "reference": "74b1b7b7019c728cb1f8672b502260e683b6374e", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", "shasum": "" }, "require": { - "composer/semver": "^3.0", - "php": ">=8.4.1", - "symfony/filesystem": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0" + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" }, "require-dev": { - "symfony/asset": "^7.4|^8.0", - "symfony/browser-kit": "^7.4|^8.0", - "symfony/console": "^7.4|^8.0", - "symfony/event-dispatcher-contracts": "^3.0", - "symfony/finder": "^7.4|^8.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/runtime": "^7.4|^8.0", - "symfony/web-link": "^7.4|^8.0" + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\AssetMapper\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Doctrine\\Common\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -403,94 +569,88 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" } ], - "description": "Maps directories of assets & makes them available in a public directory with versioned filenames.", - "homepage": "https://symfony.com", + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], "support": { - "source": "https://github.com/symfony/asset-mapper/tree/v8.1.0" + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://www.doctrine-project.org/sponsorship.html", "type": "custom" }, { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-01-29T07:11:08+00:00" }, { - "name": "symfony/cache", - "version": "v8.1.0", + "name": "doctrine/inflector", + "version": "2.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/cache.git", - "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed" + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", - "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { - "php": ">=8.4.1", - "psr/cache": "^2.0|^3.0", - "psr/log": "^1.1|^2|^3", - "symfony/cache-contracts": "^3.6", - "symfony/service-contracts": "^2.5|^3", - "symfony/var-exporter": "^8.1" - }, - "conflict": { - "ext-redis": "<6.1", - "ext-relay": "<0.12.1" - }, - "provide": { - "psr/cache-implementation": "2.0|3.0", - "psr/simple-cache-implementation": "1.0|2.0|3.0", - "symfony/cache-implementation": "1.1|2.0|3.0" + "php": "^7.2 || ^8.0" }, "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/dbal": "^4.3", - "predis/predis": "^1.1|^2.0", - "psr/simple-cache": "^1.0|^2.0|^3.0", - "symfony/clock": "^7.4|^8.0", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/filesystem": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Cache\\": "" - }, - "classmap": [ - "Traits/ValueWrapper.php" - ], - "exclude-from-classmap": [ - "/Tests/" - ] + "Doctrine\\Inflector\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -498,74 +658,90 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" } ], - "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", - "homepage": "https://symfony.com", + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", "keywords": [ - "caching", - "psr6" + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.1.0" + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://www.doctrine-project.org/sponsorship.html", "type": "custom" }, { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { - "name": "symfony/cache-contracts", - "version": "v3.7.0", + "name": "doctrine/instantiator", + "version": "2.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/cache-contracts.git", - "reference": "225e8a254166bd3442e370c6f50145465db63831" + "url": "https://github.com/doctrine/instantiator.git", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", - "reference": "225e8a254166bd3442e370c6f50145465db63831", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/cache": "^3.0" + "php": "^8.4" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.58" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Contracts\\Cache\\": "" + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" } }, "notification-url": "https://packagist.org/downloads/", @@ -574,85 +750,66 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" } ], - "description": "Generic abstractions related to caching", - "homepage": "https://symfony.com", + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "constructor", + "instantiate" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.1.0" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://www.doctrine-project.org/sponsorship.html", "type": "custom" }, { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", "type": "tidelift" } ], - "time": "2026-05-05T15:33:14+00:00" + "time": "2026-01-05T06:47:08+00:00" }, { - "name": "symfony/config", - "version": "v8.1.0", + "name": "doctrine/lexer", + "version": "3.0.1", "source": { "type": "git", - "url": "https://github.com/symfony/config.git", - "reference": "429783a0c649696f2058ea5ab5315f082dba6de9" + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/429783a0c649696f2058ea5ab5315f082dba6de9", - "reference": "429783a0c649696f2058ea5ab5315f082dba6de9", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^7.4|^8.0", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/service-contracts": "<2.5" + "php": "^8.1" }, "require-dev": { - "symfony/event-dispatcher": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^7.4|^8.0" + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Config\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Doctrine\\Common\\Lexer\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -660,93 +817,104 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" } ], - "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", - "homepage": "https://symfony.com", + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], "support": { - "source": "https://github.com/symfony/config/tree/v8.1.0" + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://www.doctrine-project.org/sponsorship.html", "type": "custom" }, { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2024-02-05T11:56:58+00:00" }, { - "name": "symfony/console", - "version": "v8.1.0", + "name": "doctrine/migrations", + "version": "3.9.7", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" + "url": "https://github.com/doctrine/migrations.git", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", - "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php85": "^1.32", - "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.4.6|^8.0.6" + "composer-runtime-api": "^2", + "doctrine/dbal": "^3.6 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2.0", + "php": "^8.1", + "psr/log": "^1.1.3 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.2 || ^7.0 || ^8.0" }, "conflict": { - "symfony/dependency-injection": "<8.1", - "symfony/event-dispatcher": "<8.1" - }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" + "doctrine/orm": "<2.12 || >=4" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^8.1", - "symfony/event-dispatcher": "^8.1", - "symfony/filesystem": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/lock": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "doctrine/coding-standard": "^14", + "doctrine/orm": "^2.13 || ^3", + "doctrine/persistence": "^2 || ^3 || ^4", + "doctrine/sql-formatter": "^1.0", + "ext-pdo_sqlite": "*", + "fig/log-test": "^1", + "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 || ^8.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "doctrine/sql-formatter": "Allows to generate formatted SQL with the diff command.", + "symfony/yaml": "Allows the use of yaml for migration configuration files." }, + "bin": [ + "bin/doctrine-migrations" + ], "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Console\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Doctrine\\Migrations\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -754,82 +922,1484 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Michael Simonson", + "email": "contact@mikesimonson.com" } ], - "description": "Eases the creation of beautiful and testable command line interfaces", - "homepage": "https://symfony.com", + "description": "PHP Doctrine Migrations project offer additional functionality on top of the database abstraction layer (DBAL) for versioning your database schema and easily deploying changes to it. It is a very easy to use and a powerful tool.", + "homepage": "https://www.doctrine-project.org/projects/migrations.html", "keywords": [ - "cli", - "command-line", - "console", - "terminal" + "database", + "dbal", + "migrations" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.0" + "issues": "https://github.com/doctrine/migrations/issues", + "source": "https://github.com/doctrine/migrations/tree/3.9.7" }, "funding": [ { - "url": "https://symfony.com/sponsor", + "url": "https://www.doctrine-project.org/sponsorship.html", "type": "custom" }, { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fmigrations", "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-04-23T19:33:20+00:00" }, { - "name": "symfony/dependency-injection", - "version": "v8.1.0", + "name": "doctrine/orm", + "version": "3.6.7", "source": { "type": "git", - "url": "https://github.com/symfony/dependency-injection.git", - "reference": "b6ba1f45127106885de4b77558c5ecca8feb1e1b" + "url": "https://github.com/doctrine/orm.git", + "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/b6ba1f45127106885de4b77558c5ecca8feb1e1b", - "reference": "b6ba1f45127106885de4b77558c5ecca8feb1e1b", + "url": "https://api.github.com/repos/doctrine/orm/zipball/bc217c0e19c3a9eadfa67697143b87c9ba01272c", + "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c", "shasum": "" }, "require": { - "php": ">=8.4.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/service-contracts": "^3.6", - "symfony/var-exporter": "^8.1" - }, - "conflict": { - "ext-psr": "<1.1|>=2" - }, - "provide": { - "psr/container-implementation": "1.1|2.0", - "symfony/service-implementation": "1.1|2.0|3.0" + "composer-runtime-api": "^2", + "doctrine/collections": "^2.2", + "doctrine/dbal": "^3.8.2 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2", + "doctrine/inflector": "^1.4 || ^2.0", + "doctrine/instantiator": "^1.3 || ^2", + "doctrine/lexer": "^3", + "doctrine/persistence": "^3.3.1 || ^4", + "ext-ctype": "*", + "php": "^8.1", + "psr/cache": "^1 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.3.9 || ^7.0 || ^8.0" }, "require-dev": { - "symfony/config": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/yaml": "^7.4|^8.0" + "doctrine/coding-standard": "^14.0", + "phpbench/phpbench": "^1.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "2.1.23", + "phpstan/phpstan-deprecation-rules": "^2", + "phpunit/phpunit": "^10.5.0 || ^11.5", + "psr/log": "^1 || ^2 || ^3", + "symfony/cache": "^5.4 || ^6.2 || ^7.0 || ^8.0" }, - "type": "library", + "suggest": { + "ext-dom": "Provides support for XSD validation for XML mapping files", + "symfony/cache": "Provides cache support for Setup Tool with doctrine/cache 2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\ORM\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "Object-Relational-Mapper for PHP", + "homepage": "https://www.doctrine-project.org/projects/orm.html", + "keywords": [ + "database", + "orm" + ], + "support": { + "issues": "https://github.com/doctrine/orm/issues", + "source": "https://github.com/doctrine/orm/tree/3.6.7" + }, + "time": "2026-05-25T16:45:47+00:00" + }, + { + "name": "doctrine/persistence", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/persistence.git", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "doctrine/event-manager": "^1 || ^2", + "php": "^8.1", + "psr/cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.58 || ^12", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Persistence\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Persistence project is a set of shared interfaces and functionality that the different Doctrine object mappers share.", + "homepage": "https://www.doctrine-project.org/projects/persistence.html", + "keywords": [ + "mapper", + "object", + "odm", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/persistence/issues", + "source": "https://github.com/doctrine/persistence/tree/4.2.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fpersistence", + "type": "tidelift" + } + ], + "time": "2026-04-26T12:12:52+00:00" + }, + { + "name": "doctrine/sql-formatter", + "version": "1.5.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/sql-formatter.git", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/9563949f5cd3bd12a17d12fb980528bc141c5806", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ergebnis/phpunit-slow-test-detector": "^2.20", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^10.5.58" + }, + "bin": [ + "bin/sql-formatter" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\SqlFormatter\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Dorn", + "email": "jeremy@jeremydorn.com", + "homepage": "https://jeremydorn.com/" + } + ], + "description": "a PHP SQL highlighting library", + "homepage": "https://github.com/doctrine/sql-formatter/", + "keywords": [ + "highlight", + "sql" + ], + "support": { + "issues": "https://github.com/doctrine/sql-formatter/issues", + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.4" + }, + "time": "2026-02-08T16:21:46+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/asset", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset.git", + "reference": "4bd4d143b7e53f40d45877df52eb2b18282bdac4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset/zipball/4bd4d143b7e53f40d45877df52eb2b18282bdac4", + "reference": "4bd4d143b7e53f40d45877df52eb2b18282bdac4", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/http-client": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Asset\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/asset-mapper", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset-mapper.git", + "reference": "74b1b7b7019c728cb1f8672b502260e683b6374e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/74b1b7b7019c728cb1f8672b502260e683b6374e", + "reference": "74b1b7b7019c728cb1f8672b502260e683b6374e", + "shasum": "" + }, + "require": { + "composer/semver": "^3.0", + "php": ">=8.4.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0" + }, + "require-dev": { + "symfony/asset": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/event-dispatcher-contracts": "^3.0", + "symfony/finder": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\AssetMapper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps directories of assets & makes them available in a public directory with versioned filenames.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset-mapper/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/cache", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", + "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^8.1" + }, + "conflict": { + "ext-redis": "<6.1", + "ext-relay": "<0.12.1" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "225e8a254166bd3442e370c6f50145465db63831" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", + "reference": "225e8a254166bd3442e370c6f50145465db63831", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-05T15:33:14+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/config", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "429783a0c649696f2058ea5ab5315f082dba6de9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/429783a0c649696f2058ea5ab5315f082dba6de9", + "reference": "429783a0c649696f2058ea5ab5315f082dba6de9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "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/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "b6ba1f45127106885de4b77558c5ecca8feb1e1b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/b6ba1f45127106885de4b77558c5ecca8feb1e1b", + "reference": "b6ba1f45127106885de4b77558c5ecca8feb1e1b", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^8.1" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "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/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/doctrine-bridge", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-bridge.git", + "reference": "80daf848dd39d9ff5a0f39aa6f2bf5448aa662c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/80daf848dd39d9ff5a0f39aa6f2bf5448aa662c5", + "reference": "80daf848dd39d9ff5a0f39aa6f2bf5448aa662c5", + "shasum": "" + }, + "require": { + "doctrine/event-manager": "^2", + "doctrine/persistence": "^3.1|^4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/collections": "<1.8", + "doctrine/dbal": "<4.3", + "doctrine/lexer": "<1.1", + "doctrine/orm": "<3.4", + "symfony/property-info": "<8.0" + }, + "require-dev": { + "doctrine/collections": "^1.8|^2.0", + "doctrine/data-fixtures": "^1.1|^2", + "doctrine/dbal": "^4.3", + "doctrine/orm": "^3.4", + "psr/log": "^1|^2|^3", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^8.1", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/doctrine-messenger": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Doctrine with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/doctrine-bridge/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:18:49+00:00" + }, + { + "name": "symfony/dotenv", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c", + "reference": "7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "source": "https://github.com/symfony/dotenv/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\DependencyInjection\\": "" + "Symfony\\Component\\ErrorHandler\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -849,10 +2419,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v8.1.0" + "source": "https://github.com/symfony/error-handler/tree/v8.1.0" }, "funding": [ { @@ -875,21 +2445,108 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/deprecation-contracts", + "name": "symfony/event-dispatcher", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", "version": "v3.7.0", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.1", + "psr/event-dispatcher": "^1" }, "type": "library", "extra": { @@ -902,9 +2559,9 @@ } }, "autoload": { - "files": [ - "function.php" - ] + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -920,10 +2577,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Generic abstractions related to dispatching event", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" }, "funding": [ { @@ -943,33 +2608,35 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { - "name": "symfony/dotenv", + "name": "symfony/filesystem", "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/dotenv.git", - "reference": "7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c" + "url": "https://github.com/symfony/filesystem.git", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c", - "reference": "7ed4e3a11e3c98235c70ded047d7ddf9e6ae854c", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", "shasum": "" }, "require": { - "php": ">=8.4.1" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/console": "^7.4|^8.0", "symfony/process": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Dotenv\\": "" + "Symfony\\Component\\Filesystem\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -989,15 +2656,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Registers environment variables from a .env file", + "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", - "keywords": [ - "dotenv", - "env", - "environment" - ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v8.1.0" + "source": "https://github.com/symfony/filesystem/tree/v8.1.0" }, "funding": [ { @@ -1020,46 +2682,110 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/error-handler", + "name": "symfony/finder", "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/error-handler.git", - "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" + "url": "https://github.com/symfony/finder.git", + "reference": "58d2e767a66052c1487356f953445634a8194c64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", - "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", + "reference": "58d2e767a66052c1487356f953445634a8194c64", "shasum": "" }, "require": { - "php": ">=8.4.1", - "psr/log": "^1|^2|^3", - "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^7.4|^8.0" + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/flex", + "version": "v2.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/flex.git", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/flex/zipball/4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.1", + "php": ">=8.1" }, "conflict": { - "symfony/deprecation-contracts": "<2.5" + "composer/semver": "<1.7.2", + "symfony/dotenv": "<5.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", - "symfony/webpack-encore-bundle": "^1.0|^2.0" + "composer/composer": "^2.1", + "phpunit/phpunit": "^12.4", + "symfony/dotenv": "^6.4.41|^7.4.13|^8.0.13", + "symfony/filesystem": "^6.4|^7.4|^8.0", + "symfony/process": "^6.4|^7.4|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Flex\\Flex" }, - "bin": [ - "Resources/bin/patch-type-declarations" - ], - "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\ErrorHandler\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Flex\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1068,17 +2794,13 @@ "authors": [ { "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "email": "fabien.potencier@gmail.com" } ], - "description": "Provides tools to manage errors and ease debugging PHP code", - "homepage": "https://symfony.com", + "description": "Composer plugin for Symfony", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.1.0" + "issues": "https://github.com/symfony/flex/issues", + "source": "https://github.com/symfony/flex/tree/v2.11.0" }, "funding": [ { @@ -1098,50 +2820,107 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-05-29T17:25:22+00:00" }, { - "name": "symfony/event-dispatcher", + "name": "symfony/framework-bundle", "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "6a0953f4fd8b51db6136c2628af99b7193e63256" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", - "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/6a0953f4fd8b51db6136c2628af99b7193e63256", + "reference": "6a0953f4fd8b51db6136c2628af99b7193e63256", "shasum": "" }, "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", "php": ">=8.4.1", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^8.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/event-dispatcher-contracts": "^2.5|^3" + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^8.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.33", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^3.7", + "symfony/var-exporter": "^8.1" }, "conflict": { - "symfony/security-http": "<7.4", - "symfony/service-contracts": "<2.5" - }, - "provide": { - "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0|3.0" + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/console": "<8.1", + "symfony/form": "<7.4", + "symfony/json-streamer": "<7.4", + "symfony/messenger": "<7.4.10|>=8.0,<8.0.10", + "symfony/mime": "<7.4.9|>=8.0,<8.0.9", + "symfony/security-csrf": "<7.4", + "symfony/serializer": "<7.4", + "symfony/translation": "<7.4", + "symfony/webhook": "<7.4", + "symfony/workflow": "<7.4" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/error-handler": "^7.4|^8.0", + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^7.4|^8.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/console": "^8.1", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/dotenv": "^7.4|^8.0", "symfony/expression-language": "^7.4|^8.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^7.4|^8.0" + "symfony/form": "^7.4|^8.0", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/json-streamer": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/mailer": "^7.4|^8.0", + "symfony/messenger": "^7.4.10|^8.0.10", + "symfony/mime": "^7.4.9|^8.0.9", + "symfony/notifier": "^7.4|^8.0", + "symfony/object-mapper": "^7.4.9|^8.0.9", + "symfony/polyfill-intl-icu": "^1.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/scheduler": "^7.4|^8.0", + "symfony/security-bundle": "^7.4|^8.0", + "symfony/semaphore": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/string": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0", + "symfony/type-info": "^7.4.1|^8.0.1", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/webhook": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, - "type": "library", + "type": "symfony-bundle", "autoload": { "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" + "Symfony\\Bundle\\FrameworkBundle\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1161,10 +2940,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" + "source": "https://github.com/symfony/framework-bundle/tree/v8.1.0" }, "funding": [ { @@ -1187,37 +2966,59 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.0", + "name": "symfony/http-client", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + "url": "https://github.com/symfony/http-client.git", + "reference": "68a48e4c31f63fcd1bdff997a85a09e55efe8cdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", - "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "url": "https://api.github.com/repos/symfony/http-client/zipball/68a48e4c31f63fcd1bdff997a85a09e55efe8cdb", + "reference": "68a48e4c31f63fcd1bdff997a85a09e55efe8cdb", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/event-dispatcher": "^1" + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/http-client-contracts": "^3.7", + "symfony/service-contracts": "^2.5|^3" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } + "conflict": { + "amphp/amp": "<3", + "php-http/discovery": "<1.15" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/guzzle": "^7.10", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Contracts\\EventDispatcher\\": "" - } + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1233,18 +3034,13 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to dispatching event", + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "http" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/http-client/tree/v8.1.0" }, "funding": [ { @@ -1264,38 +3060,41 @@ "type": "tidelift" } ], - "time": "2026-01-05T13:30:16+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/filesystem", - "version": "v8.1.0", + "name": "symfony/http-client-contracts", + "version": "v3.7.0", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^7.4|^8.0" + "php": ">=8.1" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\Filesystem\\": "" + "Symfony\\Contracts\\HttpClient\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1304,18 +3103,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides basic utilities for the filesystem", + "description": "Generic abstractions related to HTTP clients", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" }, "funding": [ { @@ -1335,32 +3142,45 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-03-06T13:17:50+00:00" }, { - "name": "symfony/finder", + "name": "symfony/http-foundation", "version": "v8.1.0", "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "58d2e767a66052c1487356f953445634a8194c64" + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", - "reference": "58d2e767a66052c1487356f953445634a8194c64", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e", + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e", "shasum": "" }, "require": { - "php": ">=8.4.1" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" }, "require-dev": { - "symfony/filesystem": "^7.4|^8.0" + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Finder\\": "" + "Symfony\\Component\\HttpFoundation\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1380,10 +3200,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.0" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.0" }, "funding": [ { @@ -1406,42 +3226,74 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/flex", - "version": "v2.11.0", + "name": "symfony/http-kernel", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/flex.git", - "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99" + "url": "https://github.com/symfony/http-kernel.git", + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/flex/zipball/4a6d98eea3ebc7f68d82810cb682eedca2649e99", - "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", "shasum": "" }, "require": { - "composer-plugin-api": "^2.1", - "php": ">=8.1" + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "composer/semver": "<1.7.2", - "symfony/dotenv": "<5.4" + "symfony/dependency-injection": "<8.1", + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" }, - "require-dev": { - "composer/composer": "^2.1", - "phpunit/phpunit": "^12.4", - "symfony/dotenv": "^6.4.41|^7.4.13|^8.0.13", - "symfony/filesystem": "^6.4|^7.4|^8.0", - "symfony/process": "^6.4|^7.4|^8.0" + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" }, - "type": "composer-plugin", - "extra": { - "class": "Symfony\\Flex\\Flex" + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Flex\\": "src" - } + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1450,13 +3302,17 @@ "authors": [ { "name": "Fabien Potencier", - "email": "fabien.potencier@gmail.com" + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Composer plugin for Symfony", + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/symfony/flex/issues", - "source": "https://github.com/symfony/flex/tree/v2.11.0" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.0" }, "funding": [ { @@ -1476,107 +3332,33 @@ "type": "tidelift" } ], - "time": "2026-05-29T17:25:22+00:00" + "time": "2026-05-29T08:46:08+00:00" }, { - "name": "symfony/framework-bundle", + "name": "symfony/password-hasher", "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/framework-bundle.git", - "reference": "6a0953f4fd8b51db6136c2628af99b7193e63256" + "url": "https://github.com/symfony/password-hasher.git", + "reference": "6934d16beaa4677f2c4584229fff1b51099dd7af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/6a0953f4fd8b51db6136c2628af99b7193e63256", - "reference": "6a0953f4fd8b51db6136c2628af99b7193e63256", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/6934d16beaa4677f2c4584229fff1b51099dd7af", + "reference": "6934d16beaa4677f2c4584229fff1b51099dd7af", "shasum": "" }, "require": { - "composer-runtime-api": ">=2.1", - "ext-xml": "*", - "php": ">=8.4.1", - "symfony/cache": "^7.4|^8.0", - "symfony/config": "^7.4.4|^8.0.4", - "symfony/dependency-injection": "^8.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^7.4|^8.0", - "symfony/event-dispatcher": "^8.1", - "symfony/filesystem": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^8.1", - "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php85": "^1.33", - "symfony/routing": "^7.4|^8.0", - "symfony/service-contracts": "^3.7", - "symfony/var-exporter": "^8.1" - }, - "conflict": { - "doctrine/persistence": "<1.3", - "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/console": "<8.1", - "symfony/form": "<7.4", - "symfony/json-streamer": "<7.4", - "symfony/messenger": "<7.4.10|>=8.0,<8.0.10", - "symfony/mime": "<7.4.9|>=8.0,<8.0.9", - "symfony/security-csrf": "<7.4", - "symfony/serializer": "<7.4", - "symfony/translation": "<7.4", - "symfony/webhook": "<7.4", - "symfony/workflow": "<7.4" + "php": ">=8.4.1" }, "require-dev": { - "doctrine/persistence": "^1.3|^2|^3", - "dragonmantank/cron-expression": "^3.1", - "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "phpstan/phpdoc-parser": "^1.0|^2.0", - "seld/jsonlint": "^1.10", - "symfony/asset": "^7.4|^8.0", - "symfony/asset-mapper": "^7.4|^8.0", - "symfony/browser-kit": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/console": "^8.1", - "symfony/css-selector": "^7.4|^8.0", - "symfony/dom-crawler": "^7.4|^8.0", - "symfony/dotenv": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/form": "^7.4|^8.0", - "symfony/html-sanitizer": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/json-streamer": "^7.4|^8.0", - "symfony/lock": "^7.4|^8.0", - "symfony/mailer": "^7.4|^8.0", - "symfony/messenger": "^7.4.10|^8.0.10", - "symfony/mime": "^7.4.9|^8.0.9", - "symfony/notifier": "^7.4|^8.0", - "symfony/object-mapper": "^7.4.9|^8.0.9", - "symfony/polyfill-intl-icu": "^1.0", - "symfony/process": "^7.4|^8.0", - "symfony/property-info": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0", - "symfony/runtime": "^7.4|^8.0", - "symfony/scheduler": "^7.4|^8.0", - "symfony/security-bundle": "^7.4|^8.0", - "symfony/semaphore": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/string": "^7.4|^8.0", - "symfony/translation": "^7.4|^8.0", - "symfony/twig-bundle": "^7.4|^8.0", - "symfony/type-info": "^7.4.1|^8.0.1", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/web-link": "^7.4|^8.0", - "symfony/webhook": "^7.4|^8.0", - "symfony/workflow": "^7.4|^8.0", - "symfony/yaml": "^7.4|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0" }, - "type": "symfony-bundle", + "type": "library", "autoload": { "psr-4": { - "Symfony\\Bundle\\FrameworkBundle\\": "" + "Symfony\\Component\\PasswordHasher\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1588,18 +3370,22 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "description": "Provides password hashing utilities", "homepage": "https://symfony.com", + "keywords": [ + "hashing", + "password" + ], "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.1.0" + "source": "https://github.com/symfony/password-hasher/tree/v8.1.0" }, "funding": [ { @@ -1622,58 +3408,44 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/http-client", - "version": "v8.1.0", + "name": "symfony/polyfill-deepclone", + "version": "v1.39.0", "source": { "type": "git", - "url": "https://github.com/symfony/http-client.git", - "reference": "68a48e4c31f63fcd1bdff997a85a09e55efe8cdb" + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "1b034bc050d84cc9c187de373f744912e1e35f1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/68a48e4c31f63fcd1bdff997a85a09e55efe8cdb", - "reference": "68a48e4c31f63fcd1bdff997a85a09e55efe8cdb", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/http-client-contracts": "^3.7", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "amphp/amp": "<3", - "php-http/discovery": "<1.15" - }, - "provide": { - "php-http/async-client-implementation": "*", - "php-http/client-implementation": "*", - "psr/http-client-implementation": "1.0", - "symfony/http-client-implementation": "3.0" - }, - "require-dev": { - "amphp/http-client": "^5.3.2", - "amphp/http-tunnel": "^2.0", - "guzzlehttp/guzzle": "^7.10", - "nyholm/psr7": "^1.0", - "php-http/httplug": "^1.0|^2.0", - "psr/http-client": "^1.0", - "symfony/cache": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0" + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/1b034bc050d84cc9c187de373f744912e1e35f1f", + "reference": "1b034bc050d84cc9c187de373f744912e1e35f1f", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\HttpClient\\": "" + "Symfony\\Polyfill\\DeepClone\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1690,13 +3462,17 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "description": "Symfony polyfill for the deepclone extension", "homepage": "https://symfony.com", "keywords": [ - "http" + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.1.0" + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.39.0" }, "funding": [ { @@ -1716,42 +3492,42 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-10T20:07:50+00:00" }, { - "name": "symfony/http-client-contracts", - "version": "v3.7.0", + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Contracts\\HttpClient\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1767,18 +3543,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to HTTP clients", + "description": "Symfony polyfill for intl's grapheme_* functions", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -1798,48 +3574,44 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:50+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { - "name": "symfony/http-foundation", - "version": "v8.1.0", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", "source": { "type": "git", - "url": "https://github.com/symfony/http-foundation.git", - "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e", - "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "^1.1" - }, - "conflict": { - "doctrine/dbal": "<4.3" + "php": ">=7.2" }, - "require-dev": { - "doctrine/dbal": "^4.3", - "predis/predis": "^1.1|^2.0", - "symfony/cache": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\HttpFoundation\\": "" + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1848,18 +3620,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Defines an object-oriented layer for the HTTP specification", + "description": "Symfony polyfill for intl's Normalizer class and related functions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -1879,77 +3659,46 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { - "name": "symfony/http-kernel", - "version": "v8.1.0", + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/symfony/http-kernel.git", - "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", - "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { - "php": ">=8.4.1", - "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/dependency-injection": "<8.1", - "symfony/flex": "<2.10", - "symfony/http-client-contracts": "<2.5", - "symfony/translation-contracts": "<2.5", - "symfony/var-dumper": "<8.1", - "symfony/web-profiler-bundle": "<8.1", - "twig/twig": "<3.21" + "ext-iconv": "*", + "php": ">=7.2" }, "provide": { - "psr/log-implementation": "1.0|2.0|3.0" + "ext-mbstring": "*" }, - "require-dev": { - "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/config": "^7.4|^8.0", - "symfony/console": "^7.4|^8.0", - "symfony/css-selector": "^7.4|^8.0", - "symfony/dependency-injection": "^8.1", - "symfony/dom-crawler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", - "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^7.4|^8.0", - "symfony/property-access": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0", - "symfony/routing": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/translation": "^7.4|^8.0", - "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^8.1", - "symfony/var-exporter": "^7.4|^8.0", - "twig/twig": "^3.21" + "suggest": { + "ext-mbstring": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\HttpKernel\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1957,18 +3706,25 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a structured process for converting a Request into a Response", + "description": "Symfony polyfill for the Mbstring extension", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -1988,30 +3744,24 @@ "type": "tidelift" } ], - "time": "2026-05-29T08:46:08+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { - "name": "symfony/polyfill-deepclone", - "version": "v1.39.0", + "name": "symfony/polyfill-php85", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "1b034bc050d84cc9c187de373f744912e1e35f1f" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/1b034bc050d84cc9c187de373f744912e1e35f1f", - "reference": "1b034bc050d84cc9c187de373f744912e1e35f1f", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { - "php": ">=8.1" - }, - "provide": { - "ext-deepclone": "*" - }, - "suggest": { - "ext-deepclone": "For best performance" + "php": ">=7.2" }, "type": "library", "extra": { @@ -2025,7 +3775,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\DeepClone\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, "classmap": [ "Resources/stubs" @@ -2045,17 +3795,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the deepclone extension", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "deepclone", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.39.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -2075,42 +3824,33 @@ "type": "tidelift" } ], - "time": "2026-06-10T20:07:50+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "name": "symfony/process", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "url": "https://github.com/symfony/process.git", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", "shasum": "" }, "require": { - "php": ">=7.2" - }, - "suggest": { - "ext-intl": "For best performance" + "php": ">=8.4.1" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2118,26 +3858,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", + "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/process/tree/v8.1.0" }, "funding": [ { @@ -2157,44 +3889,37 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "name": "symfony/property-access", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "url": "https://github.com/symfony/property-access.git", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" }, - "suggest": { - "ext-intl": "For best performance" + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + "Symfony\\Component\\PropertyAccess\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2203,26 +3928,29 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/property-access/tree/v8.1.0" }, "funding": [ { @@ -2242,46 +3970,46 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "name": "symfony/property-info", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "url": "https://github.com/symfony/property-info.git", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7", "shasum": "" }, "require": { - "ext-iconv": "*", - "php": ">=7.2" + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" }, - "provide": { - "ext-mbstring": "*" + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" }, - "suggest": { - "ext-mbstring": "For best performance" + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2289,25 +4017,26 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Extracts information about PHP class' properties using metadata of popular sources", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/property-info/tree/v8.1.0" }, "funding": [ { @@ -2327,41 +4056,41 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "name": "symfony/routing", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "url": "https://github.com/symfony/routing.git", + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" + "Symfony\\Component\\Routing\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2370,24 +4099,24 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "description": "Maps an HTTP request to a set of configuration variables", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "router", + "routing", + "uri", + "url" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/routing/tree/v8.1.0" }, "funding": [ { @@ -2407,29 +4136,45 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/process", + "name": "symfony/runtime", "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" + "url": "https://github.com/symfony/runtime.git", + "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", - "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "url": "https://api.github.com/repos/symfony/runtime/zipball/b7ea1abe04561e814b3134db0f56c287cedb35cc", + "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc", "shasum": "" }, "require": { + "composer-plugin-api": "^1.0|^2.0", "php": ">=8.4.1" }, - "type": "library", + "conflict": { + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "composer/composer": "^2.6", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dotenv": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" + }, "autoload": { "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Component\\Runtime\\": "", + "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" }, "exclude-from-classmap": [ "/Tests/" @@ -2441,18 +4186,21 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Executes commands in sub-processes", + "description": "Enables decoupling PHP applications from global state", "homepage": "https://symfony.com", + "keywords": [ + "runtime" + ], "support": { - "source": "https://github.com/symfony/process/tree/v8.1.0" + "source": "https://github.com/symfony/runtime/tree/v8.1.0" }, "funding": [ { @@ -2475,31 +4223,62 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/property-access", + "name": "symfony/security-bundle", "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/property-access.git", - "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" + "url": "https://github.com/symfony/security-bundle.git", + "reference": "0489a6247f729652db9b9ff408f69ac3bee3589e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", - "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/0489a6247f729652db9b9ff408f69ac3bee3589e", + "reference": "0489a6247f729652db9b9ff408f69ac3bee3589e", "shasum": "" }, "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", "php": ">=8.4.1", - "symfony/property-info": "^7.4.4|^8.0.4" + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/password-hasher": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/security-http": "^8.1", + "symfony/service-contracts": "^2.5|^3" }, "require-dev": { - "symfony/cache": "^7.4|^8.0", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/asset": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/ldap": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0", + "web-token/jwt-library": "^3.3.2|^4.0" }, - "type": "library", + "type": "symfony-bundle", "autoload": { "psr-4": { - "Symfony\\Component\\PropertyAccess\\": "" + "Symfony\\Bundle\\SecurityBundle\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2519,21 +4298,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", - "keywords": [ - "access", - "array", - "extraction", - "index", - "injection", - "object", - "property", - "property-path", - "reflection" - ], "support": { - "source": "https://github.com/symfony/property-access/tree/v8.1.0" + "source": "https://github.com/symfony/security-bundle/tree/v8.1.0" }, "funding": [ { @@ -2556,39 +4324,44 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/property-info", + "name": "symfony/security-core", "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/property-info.git", - "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7" + "url": "https://github.com/symfony/security-core.git", + "reference": "a8239abe61dafdd0c01c0b4019138b2855717f97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7", - "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "url": "https://api.github.com/repos/symfony/security-core/zipball/a8239abe61dafdd0c01c0b4019138b2855717f97", + "reference": "a8239abe61dafdd0c01c0b4019138b2855717f97", "shasum": "" }, "require": { "php": ">=8.4.1", - "symfony/string": "^7.4|^8.0", - "symfony/type-info": "^7.4.7|^8.0.7" - }, - "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1" + "symfony/event-dispatcher-contracts": "^2.5|^3", + "symfony/password-hasher": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "phpstan/phpdoc-parser": "^1.0|^2.0", + "psr/cache": "^1.0|^2.0|^3.0", + "psr/container": "^1.1|^2.0", + "psr/log": "^1|^2|^3", "symfony/cache": "^7.4|^8.0", "symfony/dependency-injection": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0" + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/ldap": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/string": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\PropertyInfo\\": "" + "Symfony\\Component\\Security\\Core\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2600,26 +4373,18 @@ ], "authors": [ { - "name": "Kévin Dunglas", - "email": "dunglas@gmail.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Extracts information about PHP class' properties using metadata of popular sources", + "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", - "keywords": [ - "doctrine", - "phpdoc", - "property", - "symfony", - "type", - "validator" - ], "support": { - "source": "https://github.com/symfony/property-info/tree/v8.1.0" + "source": "https://github.com/symfony/security-core/tree/v8.1.0" }, "funding": [ { @@ -2642,35 +4407,33 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/routing", + "name": "symfony/security-csrf", "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/routing.git", - "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" + "url": "https://github.com/symfony/security-csrf.git", + "reference": "c865a8ee0d30b14545d7e5349b8e443f4fa9dc3f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", - "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/c865a8ee0d30b14545d7e5349b8e443f4fa9dc3f", + "reference": "c865a8ee0d30b14545d7e5349b8e443f4fa9dc3f", "shasum": "" }, "require": { "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/security-core": "^7.4|^8.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", - "symfony/yaml": "^7.4|^8.0" + "symfony/http-kernel": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Routing\\": "" + "Symfony\\Component\\Security\\Csrf\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2690,16 +4453,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Maps an HTTP request to a set of configuration variables", + "description": "Symfony Security Component - CSRF Library", "homepage": "https://symfony.com", - "keywords": [ - "router", - "routing", - "uri", - "url" - ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.0" + "source": "https://github.com/symfony/security-csrf/tree/v8.1.0" }, "funding": [ { @@ -2722,42 +4479,49 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/runtime", + "name": "symfony/security-http", "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/runtime.git", - "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc" + "url": "https://github.com/symfony/security-http.git", + "reference": "e0e6c7b9e80eec37248b92359cbd6938c7086f4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/b7ea1abe04561e814b3134db0f56c287cedb35cc", - "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc", + "url": "https://api.github.com/repos/symfony/security-http/zipball/e0e6c7b9e80eec37248b92359cbd6938c7086f4b", + "reference": "e0e6c7b9e80eec37248b92359cbd6938c7086f4b", "shasum": "" }, "require": { - "composer-plugin-api": "^1.0|^2.0", - "php": ">=8.4.1" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^8.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/error-handler": "<7.4" + "symfony/http-client-contracts": "<3.0" }, "require-dev": { - "composer/composer": "^2.6", - "symfony/console": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/dotenv": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0" - }, - "type": "composer-plugin", - "extra": { - "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" + "psr/log": "^1|^2|^3", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/http-client-contracts": "^3.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "web-token/jwt-library": "^3.3.2|^4.0" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Runtime\\": "", - "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" + "Symfony\\Component\\Security\\Http\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2769,21 +4533,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Enables decoupling PHP applications from global state", + "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", - "keywords": [ - "runtime" - ], "support": { - "source": "https://github.com/symfony/runtime/tree/v8.1.0" + "source": "https://github.com/symfony/security-http/tree/v8.1.0" }, "funding": [ { @@ -2918,35 +4679,101 @@ "conflict": { "symfony/asset-mapper": "<6.4" }, - "require-dev": { - "phpunit/phpunit": "^11.1|^12.0", - "symfony/asset-mapper": "^7.4|^8.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/twig-bundle": "^7.4|^8.0", - "zenstruck/browser": "^1.9" + "require-dev": { + "phpunit/phpunit": "^11.1|^12.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0", + "zenstruck/browser": "^1.9" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\UX\\StimulusBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Integration with your Symfony app & Stimulus!", + "keywords": [ + "symfony-ux" + ], + "support": { + "source": "https://github.com/symfony/stimulus-bundle/tree/v3.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-05-22T05:04:55+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "21c07b026905d596e8379caeb115d87aa479499d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", + "reference": "21c07b026905d596e8379caeb115d87aa479499d", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/service-contracts": "^2.5|^3" }, - "type": "symfony-bundle", + "type": "library", "autoload": { "psr-4": { - "Symfony\\UX\\StimulusBundle\\": "src" - } + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Integration with your Symfony app & Stimulus!", - "keywords": [ - "symfony-ux" - ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stimulus-bundle/tree/v3.1.0" + "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" }, "funding": [ { @@ -2966,7 +4793,7 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/string", @@ -4262,6 +6089,177 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "doctrine/data-fixtures", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/data-fixtures.git", + "reference": "bf7ac3a050b54b261cedfb3d0a44733819062275" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/bf7ac3a050b54b261cedfb3d0a44733819062275", + "reference": "bf7ac3a050b54b261cedfb3d0a44733819062275", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^3.1 || ^4.0", + "php": "^8.1", + "psr/log": "^1.1 || ^2 || ^3" + }, + "conflict": { + "doctrine/dbal": "<3.5 || >=5", + "doctrine/orm": "<2.14 || >=4", + "doctrine/phpcr-odm": "<1.3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "doctrine/dbal": "^3.5 || ^4", + "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", + "doctrine/orm": "^2.14 || ^3", + "doctrine/phpcr-odm": "^1.8 || ^2.0", + "ext-sqlite3": "*", + "fig/log-test": "^1", + "jackalope/jackalope-fs": "*", + "phpstan/phpstan": "2.1.46", + "phpunit/phpunit": "10.5.63 || 12.5.12", + "symfony/cache": "^6.4 || ^7 || ^8", + "symfony/var-exporter": "^6.4 || ^7 || ^8" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)", + "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures", + "doctrine/orm": "For loading ORM fixtures", + "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\DataFixtures\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Data Fixtures for all Doctrine Object Managers", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "database" + ], + "support": { + "issues": "https://github.com/doctrine/data-fixtures/issues", + "source": "https://github.com/doctrine/data-fixtures/tree/2.2.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures", + "type": "tidelift" + } + ], + "time": "2026-04-01T13:56:01+00:00" + }, + { + "name": "doctrine/doctrine-fixtures-bundle", + "version": "4.3.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d", + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d", + "shasum": "" + }, + "require": { + "doctrine/data-fixtures": "^2.2", + "doctrine/doctrine-bundle": "^2.2 || ^3.0", + "doctrine/orm": "^2.14.0 || ^3.0", + "doctrine/persistence": "^2.4 || ^3.0 || ^4.0", + "php": "^8.1", + "psr/log": "^2 || ^3", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0" + }, + "conflict": { + "doctrine/dbal": "< 3" + }, + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "phpstan/phpstan": "2.1.11", + "phpunit/phpunit": "^10.5.38 || 11.4.14" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\FixturesBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DoctrineFixturesBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "Fixture", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", + "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle", + "type": "tidelift" + } + ], + "time": "2025-12-03T16:05:42+00:00" + }, { "name": "ergebnis/agent-detector", "version": "1.2.0", @@ -7727,6 +9725,105 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/maker-bundle", + "version": "v1.67.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/maker-bundle.git", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1", + "doctrine/inflector": "^2.0", + "nikic/php-parser": "^5.0", + "php": ">=8.1", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/doctrine-bundle": "<2.10", + "doctrine/orm": "<2.15" + }, + "require-dev": { + "composer/semver": "^3.0", + "doctrine/doctrine-bundle": "^2.10|^3.0", + "doctrine/orm": "^2.15|^3", + "doctrine/persistence": "^3.1|^4.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/twig": "^3.0|^4.x-dev" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MakerBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.", + "homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html", + "keywords": [ + "code generator", + "dev", + "generator", + "scaffold", + "scaffolding" + ], + "support": { + "issues": "https://github.com/symfony/maker-bundle/issues", + "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2026-03-18T13:39:06+00:00" + }, { "name": "symfony/options-resolver", "version": "v8.1.0", @@ -7883,72 +9980,6 @@ ], "time": "2026-05-29T05:06:50+00:00" }, - { - "name": "symfony/stopwatch", - "version": "v8.1.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/stopwatch.git", - "reference": "21c07b026905d596e8379caeb115d87aa479499d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", - "reference": "21c07b026905d596e8379caeb115d87aa479499d", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "symfony/service-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Stopwatch\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides a way to profile code", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "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": "2026-05-29T05:06:50+00:00" - }, { "name": "theseer/tokenizer", "version": "1.3.1", diff --git a/config/bundles.php b/config/bundles.php index 76e2980..744b525 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -7,4 +7,9 @@ Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Symfonycasts\TailwindBundle\SymfonycastsTailwindBundle::class => ['all' => true], Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], + Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 0000000..c196bab --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,39 @@ +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + profiling_collect_backtrace: '%kernel.debug%' + orm: + validate_xml_mapping: true + naming_strategy: doctrine.orm.naming_strategy.underscore + auto_mapping: true + mappings: + App: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Entity' + prefix: 'App\Entity' + alias: App + +when@test: + doctrine: + dbal: + # "TEST_TOKEN" is typically set by ParaTest + dbname_suffix: '_test%env(default::TEST_TOKEN)%' + +when@prod: + doctrine: + orm: + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + + framework: + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.system diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml new file mode 100644 index 0000000..29231d9 --- /dev/null +++ b/config/packages/doctrine_migrations.yaml @@ -0,0 +1,6 @@ +doctrine_migrations: + migrations_paths: + # namespace is arbitrary but should be different from App\Migrations + # as migrations classes should NOT be autoloaded + 'DoctrineMigrations': '%kernel.project_dir%/migrations' + enable_profiler: false diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 0000000..bad06f3 --- /dev/null +++ b/config/packages/security.yaml @@ -0,0 +1,45 @@ +security: + # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + + # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider + providers: + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: email + + firewalls: + dev: + # Ensure dev tools and static assets are always allowed + pattern: ^/(_profiler|_wdt|assets|build)/ + security: false + main: + lazy: true + provider: app_user_provider + form_login: + login_path: app_login + check_path: app_login + enable_csrf: true + default_target_path: app_frontpage + logout: + path: app_logout + target: app_frontpage + + # Note: Only the *first* matching rule is applied + access_control: + - { path: ^/login, roles: PUBLIC_ACCESS } + - { path: ^/logout, roles: PUBLIC_ACCESS } + +when@test: + security: + password_hashers: + # Password hashers are resource-intensive by design to ensure security. + # In tests, it's safe to reduce their cost to improve performance. + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + cost: 4 # Lowest possible value for bcrypt + time_cost: 3 # Lowest possible value for argon + memory_cost: 10 # Lowest possible value for argon diff --git a/config/reference.php b/config/reference.php index f852f60..40bcddb 100644 --- a/config/reference.php +++ b/config/reference.php @@ -805,6 +805,528 @@ * collect_components?: bool|Param, // Collect components instances // Default: true * }, * } + * @psalm-type DoctrineConfig = array{ + * dbal?: array{ + * default_connection?: scalar|Param|null, + * types?: array, + * driver_schemes?: array, + * connections?: array, + * mapping_types?: array, + * default_table_options?: array, + * schema_manager_factory?: scalar|Param|null, // Default: "doctrine.dbal.default_schema_manager_factory" + * result_cache?: scalar|Param|null, + * replicas?: array, + * }>, + * }, + * orm?: array{ + * default_entity_manager?: scalar|Param|null, + * enable_native_lazy_objects?: bool|Param, // Deprecated: The "enable_native_lazy_objects" option is deprecated and will be removed in DoctrineBundle 4.0, as native lazy objects are now always enabled. // Default: true + * controller_resolver?: bool|array{ + * enabled?: bool|Param, // Default: true + * auto_mapping?: bool|Param, // Deprecated: The "doctrine.orm.controller_resolver.auto_mapping.auto_mapping" option is deprecated and will be removed in DoctrineBundle 4.0, as it only accepts `false` since 3.0. // Set to true to enable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: false + * evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false + * }, + * entity_managers?: array, + * }>, + * }>, + * }, + * connection?: scalar|Param|null, + * class_metadata_factory_name?: scalar|Param|null, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory" + * default_repository_class?: scalar|Param|null, // Default: "Doctrine\\ORM\\EntityRepository" + * auto_mapping?: scalar|Param|null, // Default: false + * naming_strategy?: scalar|Param|null, // Default: "doctrine.orm.naming_strategy.default" + * quote_strategy?: scalar|Param|null, // Default: "doctrine.orm.quote_strategy.default" + * typed_field_mapper?: scalar|Param|null, // Default: "doctrine.orm.typed_field_mapper.default" + * entity_listener_resolver?: scalar|Param|null, // Default: null + * fetch_mode_subselect_batch_size?: scalar|Param|null, + * repository_factory?: scalar|Param|null, // Default: "doctrine.orm.container_repository_factory" + * schema_ignore_classes?: list, + * validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/6728. // Default: false + * second_level_cache?: array{ + * region_cache_driver?: string|array{ + * type?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, + * pool?: scalar|Param|null, + * }, + * region_lock_lifetime?: scalar|Param|null, // Default: 60 + * log_enabled?: bool|Param, // Default: true + * region_lifetime?: scalar|Param|null, // Default: 3600 + * enabled?: bool|Param, // Default: true + * factory?: scalar|Param|null, + * regions?: array, + * loggers?: array, + * }, + * hydrators?: array, + * mappings?: array, + * dql?: array{ + * string_functions?: array, + * numeric_functions?: array, + * datetime_functions?: array, + * }, + * filters?: array, + * }>, + * identity_generation_preferences?: array, + * }>, + * resolve_target_entities?: array, + * }, + * } + * @psalm-type DoctrineMigrationsConfig = array{ + * enable_service_migrations?: bool|Param, // Whether to enable fetching migrations from the service container. // Default: false + * migrations_paths?: array, + * services?: array, + * factories?: array, + * storage?: array{ // Storage to use for migration status metadata. + * table_storage?: array{ // The default metadata storage, implemented as a table in the database. + * table_name?: scalar|Param|null, // Default: null + * version_column_name?: scalar|Param|null, // Default: null + * version_column_length?: scalar|Param|null, // Default: null + * executed_at_column_name?: scalar|Param|null, // Default: null + * execution_time_column_name?: scalar|Param|null, // Default: null + * }, + * }, + * migrations?: list, + * connection?: scalar|Param|null, // Connection name to use for the migrations database. // Default: null + * em?: scalar|Param|null, // Entity manager name to use for the migrations database (available when doctrine/orm is installed). // Default: null + * all_or_nothing?: scalar|Param|null, // Run all migrations in a transaction. // Default: false + * check_database_platform?: scalar|Param|null, // Adds an extra check in the generated migrations to allow execution only on the same platform as they were initially generated on. // Default: true + * custom_template?: scalar|Param|null, // Custom template path for generated migration classes. // Default: null + * organize_migrations?: scalar|Param|null, // Organize migrations mode. Possible values are: "BY_YEAR", "BY_YEAR_AND_MONTH", false // Default: false + * enable_profiler?: bool|Param, // Whether or not to enable the profiler collector to calculate and visualize migration status. This adds some queries overhead. // Default: false + * transactional?: bool|Param, // Whether or not to wrap migrations in a single transaction. // Default: true + * } + * @psalm-type SecurityConfig = array{ + * access_denied_url?: scalar|Param|null, // Default: null + * session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate" + * expose_security_errors?: \Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::None|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::AccountStatus|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::All|Param, // Default: "none" + * erase_credentials?: bool|Param, // Deprecated: Setting the "security.erase_credentials.erase_credentials" configuration option is deprecated. It will be removed in Symfony 9.0, as the "eraseCredentials()" method was removed in Symfony 8.0. // Default: true + * access_decision_manager?: array{ + * strategy?: "affirmative"|"consensus"|"unanimous"|"priority"|Param, + * service?: scalar|Param|null, + * strategy_service?: scalar|Param|null, + * allow_if_all_abstain?: bool|Param, // Default: false + * allow_if_equal_granted_denied?: bool|Param, // Default: true + * }, + * password_hashers?: array, + * hash_algorithm?: scalar|Param|null, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" + * key_length?: scalar|Param|null, // Default: 40 + * ignore_case?: bool|Param, // Default: false + * encode_as_base64?: bool|Param, // Default: true + * iterations?: scalar|Param|null, // Default: 5000 + * cost?: int|Param, // Default: null + * memory_cost?: scalar|Param|null, // Default: null + * time_cost?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, + * }>, + * providers?: array, + * }, + * entity?: array{ + * class?: scalar|Param|null, // The full entity class name of your user class. + * property?: scalar|Param|null, // Default: null + * manager_name?: scalar|Param|null, // Default: null + * }, + * memory?: array{ + * users?: array, + * }>, + * }, + * ldap?: array{ + * service?: scalar|Param|null, + * base_dn?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: null + * search_password?: scalar|Param|null, // Default: null + * extra_fields?: list, + * default_roles?: string|list, + * role_fetcher?: scalar|Param|null, // Default: null + * uid_key?: scalar|Param|null, // Default: "sAMAccountName" + * filter?: scalar|Param|null, // Default: "({uid_key}={user_identifier})" + * password_attribute?: scalar|Param|null, // Default: null + * }, + * }>, + * firewalls?: array, + * security?: bool|Param, // Default: true + * user_checker?: scalar|Param|null, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" + * request_matcher?: scalar|Param|null, + * access_denied_url?: scalar|Param|null, + * access_denied_handler?: scalar|Param|null, + * entry_point?: scalar|Param|null, // An enabled authenticator name or a service id that implements "Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface". + * provider?: scalar|Param|null, + * stateless?: bool|Param, // Default: false + * lazy?: bool|Param, // Default: false + * context?: scalar|Param|null, + * logout?: array{ + * enable_csrf?: bool|Param|null, // Default: null + * csrf_token_id?: scalar|Param|null, // Default: "logout" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_manager?: scalar|Param|null, + * path?: scalar|Param|null, // Default: "/logout" + * target?: scalar|Param|null, // Default: "/" + * invalidate_session?: bool|Param, // Default: true + * clear_site_data?: string|list<"*"|"cache"|"cookies"|"storage"|"clientHints"|"executionContexts"|"prefetchCache"|"prerenderCache"|Param>, + * delete_cookies?: string|array, + * }, + * switch_user?: array{ + * provider?: scalar|Param|null, + * parameter?: scalar|Param|null, // Default: "_switch_user" + * role?: scalar|Param|null, // Default: "ROLE_ALLOWED_TO_SWITCH" + * target_route?: scalar|Param|null, // Default: null + * }, + * required_badges?: list, + * custom_authenticators?: list, + * login_throttling?: array{ + * limiter?: scalar|Param|null, // A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface". + * max_attempts?: int|Param, // Default: 5 + * interval?: scalar|Param|null, // Default: "1 minute" + * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null + * cache_pool?: string|Param, // The cache pool to use for storing the limiter state // Default: "cache.rate_limiter" + * storage_service?: string|Param, // The service ID of a custom storage implementation, this precedes any configured "cache_pool" // Default: null + * }, + * x509?: array{ + * provider?: scalar|Param|null, + * user?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN_Email" + * credentials?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN" + * user_identifier?: scalar|Param|null, // Default: "emailAddress" + * }, + * remote_user?: array{ + * provider?: scalar|Param|null, + * user?: scalar|Param|null, // Default: "REMOTE_USER" + * }, + * login_link?: array{ + * check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". + * check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false + * signature_properties?: list, + * lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600 + * max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null + * used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set. + * success_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface. + * failure_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface. + * provider?: scalar|Param|null, // The user provider to load users from. + * secret?: scalar|Param|null, // Default: "%kernel.secret%" + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * login_path?: scalar|Param|null, // Default: "/login" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * }, + * form_login?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_parameter?: scalar|Param|null, // Default: "_username" + * password_parameter?: scalar|Param|null, // Default: "_password" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "authenticate" + * enable_csrf?: bool|Param, // Default: false + * post_only?: bool|Param, // Default: true + * form_only?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * }, + * form_login_ldap?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_parameter?: scalar|Param|null, // Default: "_username" + * password_parameter?: scalar|Param|null, // Default: "_password" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "authenticate" + * enable_csrf?: bool|Param, // Default: false + * post_only?: bool|Param, // Default: true + * form_only?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * json_login?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_path?: scalar|Param|null, // Default: "username" + * password_path?: scalar|Param|null, // Default: "password" + * }, + * json_login_ldap?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_path?: scalar|Param|null, // Default: "username" + * password_path?: scalar|Param|null, // Default: "password" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * access_token?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: null + * token_extractors?: string|list, + * token_handler?: string|array{ + * id?: scalar|Param|null, + * oidc_user_info?: string|array{ + * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). + * discovery?: array{ // Enable the OIDC discovery. + * cache?: array{ + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * }, + * }, + * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" + * client?: scalar|Param|null, // HttpClient service id to use to call the OIDC server. + * }, + * oidc?: array{ + * discovery?: array{ // Enable the OIDC discovery. + * base_uri?: string|list, + * cache?: array{ + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * }, + * enforce_key_usage_verification?: bool|Param, // When enabled (default), only keys explicitly designated for signature (via "use":"sig" or a "key_ops" entry containing "sign"/"verify") are accepted. When disabled, keys without any usage designation are also accepted; keys explicitly restricted to encryption are still rejected. // Default: true + * }, + * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" + * audience?: scalar|Param|null, // Audience set in the token, for validation purpose. + * issuers?: list, + * algorithms?: list, + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false + * algorithms?: list, + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). + * }, + * }, + * cas?: array{ + * validation_url?: scalar|Param|null, // CAS server validation URL + * prefix?: scalar|Param|null, // CAS prefix // Default: "cas" + * http_client?: scalar|Param|null, // HTTP Client service // Default: null + * }, + * oauth2?: scalar|Param|null, + * }, + * }, + * http_basic?: array{ + * provider?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: "Secured Area" + * }, + * http_basic_ldap?: array{ + * provider?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: "Secured Area" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * remember_me?: array{ + * secret?: scalar|Param|null, // Default: "%kernel.secret%" + * service?: scalar|Param|null, + * user_providers?: string|list, + * catch_exceptions?: bool|Param, // Default: true + * signature_properties?: list, + * token_provider?: string|array{ + * service?: scalar|Param|null, // The service ID of a custom remember-me token provider. + * doctrine?: bool|array{ + * enabled?: bool|Param, // Default: false + * connection?: scalar|Param|null, // Default: null + * }, + * }, + * token_verifier?: scalar|Param|null, // The service ID of a custom rememberme token verifier. + * name?: scalar|Param|null, // Default: "REMEMBERME" + * lifetime?: int|Param, // Default: 31536000 + * path?: scalar|Param|null, // Default: "/" + * domain?: scalar|Param|null, // Default: null + * secure?: true|false|"auto"|Param, // Default: false + * httponly?: bool|Param, // Default: true + * samesite?: null|"lax"|"strict"|"none"|Param, // Default: null + * always_remember_me?: bool|Param, // Default: false + * remember_me_parameter?: scalar|Param|null, // Default: "_remember_me" + * }, + * }>, + * access_control?: list, + * attributes?: array, + * route?: scalar|Param|null, // Default: null + * methods?: string|list, + * allow_if?: scalar|Param|null, // Default: null + * roles?: string|list, + * }>, + * role_hierarchy?: array>, + * } + * @psalm-type MakerConfig = array{ + * root_namespace?: scalar|Param|null, // Default: "App" + * generate_final_classes?: bool|Param, // Default: true + * generate_final_entities?: bool|Param, // Default: false + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -815,6 +1337,9 @@ * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, * twig_component?: TwigComponentConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -825,6 +1350,10 @@ * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, * twig_component?: TwigComponentConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * maker?: MakerConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -836,6 +1365,9 @@ * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, * twig_component?: TwigComponentConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -847,6 +1379,9 @@ * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, * twig_component?: TwigComponentConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, * }, * ...addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE `user`'); + } +} diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 07d5366..e244000 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -33,6 +33,8 @@ + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend trigger_deprecation diff --git a/src/Command/UserChangePasswordCommand.php b/src/Command/UserChangePasswordCommand.php new file mode 100644 index 0000000..614e5d0 --- /dev/null +++ b/src/Command/UserChangePasswordCommand.php @@ -0,0 +1,75 @@ + `. + * Delegates to {@see UserManager::changePassword()}. + */ +#[AsCommand( + name: 'app:user:change-password', + description: 'Set a new hashed password for an existing user.', +)] +final class UserChangePasswordCommand extends Command +{ + /** + * @param UserManager $userManager service that owns password hashing and + * persistence + */ + public function __construct(private readonly UserManager $userManager) + { + parent::__construct(); + } + + /** + * Declare CLI arguments. + */ + protected function configure(): void + { + $this + ->addArgument('email', InputArgument::REQUIRED, 'The e-mail of the user whose password to change.') + ->addArgument('password', InputArgument::REQUIRED, 'The new password in clear-text — will be hashed.'); + } + + /** + * Adapt console arguments to the {@see UserManager} call and render + * a success / error message. + * + * @param InputInterface $input CLI arguments + * @param OutputInterface $output Symfony console output stream + * + * @return int Command::SUCCESS on a successful change, + * Command::FAILURE when the user is not found or the + * password is rejected + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $email = (string) $input->getArgument('email'); + $password = (string) $input->getArgument('password'); + + try { + $user = $this->userManager->changePassword($email, $password); + } catch (\DomainException|\InvalidArgumentException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->success(\sprintf('Updated password for user "%s".', $user->getUserIdentifier())); + + return Command::SUCCESS; + } +} diff --git a/src/Command/UserCreateCommand.php b/src/Command/UserCreateCommand.php new file mode 100644 index 0000000..c0d7974 --- /dev/null +++ b/src/Command/UserCreateCommand.php @@ -0,0 +1,77 @@ + `. + * Delegates to {@see UserManager::createUser()} for the actual + * persistence and hashing — the command itself just adapts CLI input + * and renders the outcome. + */ +#[AsCommand( + name: 'app:user:create', + description: 'Create a new application user with a hashed password.', +)] +final class UserCreateCommand extends Command +{ + /** + * @param UserManager $userManager service that owns user creation, + * password hashing, and persistence + */ + public function __construct(private readonly UserManager $userManager) + { + parent::__construct(); + } + + /** + * Declare CLI arguments. + */ + protected function configure(): void + { + $this + ->addArgument('email', InputArgument::REQUIRED, 'The user\'s e-mail address (must be unique).') + ->addArgument('password', InputArgument::REQUIRED, 'The user\'s password in clear-text — will be hashed.'); + } + + /** + * Adapt console arguments to the {@see UserManager} call and render + * a success / error message. + * + * @param InputInterface $input CLI arguments + * @param OutputInterface $output Symfony console output stream + * + * @return int Command::SUCCESS on creation, Command::FAILURE when + * {@see UserManager::createUser()} throws a domain or + * validation error + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $email = (string) $input->getArgument('email'); + $password = (string) $input->getArgument('password'); + + try { + $user = $this->userManager->createUser($email, $password); + } catch (\DomainException|\InvalidArgumentException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + $io->success(\sprintf('Created user "%s" (id=%d).', $user->getUserIdentifier(), (int) $user->getId())); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000..3addc34 --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,68 @@ +render('security/login.html.twig', [ + 'last_username' => $authenticationUtils->getLastUsername(), + 'error' => $authenticationUtils->getLastAuthenticationError(), + ]); + } + + /** + * Placeholder action for the `app_logout` route. + * + * The route exists so URL generation (`{{ path('app_logout') }}`) + * works in templates, but Symfony Security intercepts the request + * and handles session invalidation before this method is called. + * The unreachable body throws so that any accidental direct call + * fails loudly. + * + * @throws \LogicException always — the firewall must intercept + */ + #[Route(path: '/logout', name: 'app_logout', methods: ['GET'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function logout(): never + { + throw new \LogicException('This method is intercepted by the logout key on the firewall.'); + } +} diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php new file mode 100644 index 0000000..d9e536a --- /dev/null +++ b/src/DataFixtures/UserFixtures.php @@ -0,0 +1,44 @@ +userManager->createUser('alice@example.test', 'password'); + $this->userManager->createUser('bob@example.test', 'password'); + } +} diff --git a/src/Entity/.gitignore b/src/Entity/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..8fee99a --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,109 @@ + The user roles + */ + #[ORM\Column] + private array $roles = []; + + /** + * @var string The hashed password + */ + #[ORM\Column] + private ?string $password = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + /** + * @param list $roles + */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + /** + * Ensure the session doesn't contain actual password hashes by CRC32C-hashing them, as supported since Symfony 7.3. + */ + public function __serialize(): array + { + $data = (array) $this; + $data["\0".self::class."\0password"] = hash('crc32c', $this->password); + + return $data; + } +} diff --git a/src/Repository/.gitignore b/src/Repository/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..2a44ea0 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,35 @@ + + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class)); + } + + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Security/UserManager.php b/src/Security/UserManager.php new file mode 100644 index 0000000..074a6da --- /dev/null +++ b/src/Security/UserManager.php @@ -0,0 +1,113 @@ + $roles additional roles to grant; the + * framework guarantees `ROLE_USER` + * implicitly so callers should leave + * this empty for plain users + * + * @return User the persisted user with an assigned id + * + * @throws \DomainException when a user with the same e-mail + * already exists + * @throws \InvalidArgumentException when `$plainPassword` is empty + */ + public function createUser(string $email, string $plainPassword, array $roles = []): User + { + if ('' === $plainPassword) { + throw new \InvalidArgumentException('Password must not be empty.'); + } + + if (null !== $this->userRepository->findOneBy(['email' => $email])) { + throw new \DomainException(\sprintf('A user with the e-mail "%s" already exists.', $email)); + } + + $user = (new User()) + ->setEmail($email) + ->setRoles($roles); + $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword)); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } + + /** + * Replace a user's password with a freshly hashed copy. + * + * @param string $email the e-mail of the user whose password + * to change + * @param string $newPlainPassword the new password in clear-text; hashed + * before persistence + * + * @return User the updated user + * + * @throws \DomainException when no user with that e-mail exists + * @throws \InvalidArgumentException when `$newPlainPassword` is empty + */ + public function changePassword(string $email, string $newPlainPassword): User + { + if ('' === $newPlainPassword) { + throw new \InvalidArgumentException('Password must not be empty.'); + } + + $user = $this->userRepository->findOneBy(['email' => $email]); + if (null === $user) { + throw new \DomainException(\sprintf('No user with the e-mail "%s" was found.', $email)); + } + + $user->setPassword($this->passwordHasher->hashPassword($user, $newPlainPassword)); + $this->entityManager->flush(); + + return $user; + } +} diff --git a/symfony.lock b/symfony.lock index f765f9e..976c559 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,4 +1,52 @@ { + "doctrine/deprecations": { + "version": "1.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "fdd756167454623e21f1d769c5b814b243782a67" + } + }, + "doctrine/doctrine-bundle": { + "version": "3.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.0", + "ref": "d39a3bd844edfe90c20ae520b804a3bf4f82b4ad" + }, + "files": [ + "config/packages/doctrine.yaml", + "src/Entity/.gitignore", + "src/Repository/.gitignore" + ] + }, + "doctrine/doctrine-fixtures-bundle": { + "version": "4.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.0", + "ref": "1f5514cfa15b947298df4d771e694e578d4c204d" + }, + "files": [ + "src/DataFixtures/AppFixtures.php" + ] + }, + "doctrine/doctrine-migrations-bundle": { + "version": "4.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.1", + "ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33" + }, + "files": [ + "config/packages/doctrine_migrations.yaml", + "migrations/.gitignore" + ] + }, "friendsofphp/php-cs-fixer": { "version": "3.95", "recipe": { @@ -86,6 +134,15 @@ ".editorconfig" ] }, + "symfony/maker-bundle": { + "version": "1.67", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" + } + }, "symfony/phpunit-bridge": { "version": "8.1", "recipe": { @@ -120,6 +177,19 @@ "config/routes.yaml" ] }, + "symfony/security-bundle": { + "version": "8.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "c42fee7802181cdd50f61b8622715829f5d2335c" + }, + "files": [ + "config/packages/security.yaml", + "config/routes/security.yaml" + ] + }, "symfony/stimulus-bundle": { "version": "3.1", "recipe": { diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig new file mode 100644 index 0000000..ee1ed48 --- /dev/null +++ b/templates/security/login.html.twig @@ -0,0 +1,49 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'security.login.title'|trans({'%brand%': brand_name}) }}{% endblock %} + +{% block body %} +
+ {{ 'security.login.eyebrow'|trans }} +

+ {{ 'security.login.heading'|trans }} +

+ + {% if error %} + + {% endif %} + +
+ + + + + + + +
+
+{% endblock %} diff --git a/tests/Command/UserChangePasswordCommandTest.php b/tests/Command/UserChangePasswordCommandTest.php new file mode 100644 index 0000000..8bf1f3a --- /dev/null +++ b/tests/Command/UserChangePasswordCommandTest.php @@ -0,0 +1,57 @@ +get(EntityManagerInterface::class)); + + $this->userManager = $container->get(UserManager::class); + + $application = new Application(self::$kernel); + $command = $application->find('app:user:change-password'); + $this->tester = new CommandTester($command); + } + + public function testChangesPassword(): void + { + $this->userManager->createUser('alice@example.test', 'old'); + + $exit = $this->tester->execute([ + 'email' => 'alice@example.test', + 'password' => 'new', + ]); + + self::assertSame(0, $exit); + self::assertStringContainsString('Updated password for user "alice@example.test"', $this->tester->getDisplay()); + } + + public function testReportsFailureWhenUserMissing(): void + { + $exit = $this->tester->execute([ + 'email' => 'nobody@example.test', + 'password' => 'new', + ]); + + self::assertSame(1, $exit); + self::assertStringContainsString('No user with the e-mail "nobody@example.test"', $this->tester->getDisplay()); + } +} diff --git a/tests/Command/UserCreateCommandTest.php b/tests/Command/UserCreateCommandTest.php new file mode 100644 index 0000000..ea39ac5 --- /dev/null +++ b/tests/Command/UserCreateCommandTest.php @@ -0,0 +1,57 @@ +get(EntityManagerInterface::class)); + + $this->userManager = $container->get(UserManager::class); + + $application = new Application(self::$kernel); + $command = $application->find('app:user:create'); + $this->tester = new CommandTester($command); + } + + public function testCreatesUser(): void + { + $exit = $this->tester->execute([ + 'email' => 'alice@example.test', + 'password' => 'secret', + ]); + + self::assertSame(0, $exit); + self::assertStringContainsString('Created user "alice@example.test"', $this->tester->getDisplay()); + } + + public function testReportsFailureWhenEmailAlreadyExists(): void + { + $this->userManager->createUser('alice@example.test', 'first'); + + $exit = $this->tester->execute([ + 'email' => 'alice@example.test', + 'password' => 'second', + ]); + + self::assertSame(1, $exit); + self::assertStringContainsString('already exists', $this->tester->getDisplay()); + } +} diff --git a/tests/Controller/SecurityControllerTest.php b/tests/Controller/SecurityControllerTest.php new file mode 100644 index 0000000..841821f --- /dev/null +++ b/tests/Controller/SecurityControllerTest.php @@ -0,0 +1,110 @@ +client = self::createClient(); + $container = self::getContainer(); + self::resetSchema($container->get(EntityManagerInterface::class)); + + $container->get(UserManager::class) + ->createUser('alice@example.test', 'secret'); + } + + public function testLoginPageRenders(): void + { + $this->client->request('GET', '/login'); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('input[name="_username"]'); + self::assertSelectorExists('input[name="_password"]'); + self::assertSelectorExists('input[name="_csrf_token"]'); + } + + public function testSuccessfulLoginRedirectsToFrontpage(): void + { + $crawler = $this->client->request('GET', '/login'); + $form = $crawler->filter('form')->form(); + $form['_username'] = 'alice@example.test'; + $form['_password'] = 'secret'; + $this->client->submit($form); + + self::assertResponseRedirects('/'); + $this->client->followRedirect(); + self::assertResponseIsSuccessful(); + + /** @var User|null $token */ + $token = $this->client->getContainer()->get('security.token_storage')->getToken()?->getUser(); + self::assertInstanceOf(User::class, $token); + self::assertSame('alice@example.test', $token->getUserIdentifier()); + } + + public function testFailedLoginShowsErrorAndStaysOnLoginPage(): void + { + $crawler = $this->client->request('GET', '/login'); + $form = $crawler->filter('form')->form(); + $form['_username'] = 'alice@example.test'; + $form['_password'] = 'wrong'; + $this->client->submit($form); + + // Form login redirects back to the login page on failure. + self::assertResponseRedirects('/login'); + $this->client->followRedirect(); + self::assertResponseIsSuccessful(); + self::assertNull( + $this->client->getContainer()->get('security.token_storage')->getToken(), + ); + } + + public function testLogoutActionThrowsWhenInvokedDirectly(): void + { + // The firewall intercepts /logout in production, so the method body + // is unreachable through HTTP. Calling it directly proves the + // defensive throw is wired correctly. + $controller = new SecurityController(); + + $this->expectException(\LogicException::class); + $controller->logout(); + } + + public function testLogoutClearsTheSession(): void + { + // Sign in first. + $crawler = $this->client->request('GET', '/login'); + $form = $crawler->filter('form')->form(); + $form['_username'] = 'alice@example.test'; + $form['_password'] = 'secret'; + $this->client->submit($form); + $this->client->followRedirect(); + + $this->client->request('GET', '/logout'); + + // Symfony intercepts /logout and redirects to the configured target. + self::assertResponseRedirects('/'); + $this->client->followRedirect(); + self::assertNull( + $this->client->getContainer()->get('security.token_storage')->getToken(), + ); + } +} diff --git a/tests/DataFixtures/UserFixturesTest.php b/tests/DataFixtures/UserFixturesTest.php new file mode 100644 index 0000000..9190abd --- /dev/null +++ b/tests/DataFixtures/UserFixturesTest.php @@ -0,0 +1,41 @@ +get(EntityManagerInterface::class); + self::resetSchema($em); + + $container->get(UserFixtures::class)->load($em); + + $repository = $container->get(UserRepository::class); + $alice = $repository->findOneBy(['email' => 'alice@example.test']); + $bob = $repository->findOneBy(['email' => 'bob@example.test']); + + self::assertInstanceOf(User::class, $alice); + self::assertInstanceOf(User::class, $bob); + } +} diff --git a/tests/Repository/UserRepositoryTest.php b/tests/Repository/UserRepositoryTest.php new file mode 100644 index 0000000..06255de --- /dev/null +++ b/tests/Repository/UserRepositoryTest.php @@ -0,0 +1,69 @@ +get(EntityManagerInterface::class)); + + $this->repository = $container->get(UserRepository::class); + $this->userManager = $container->get(UserManager::class); + } + + public function testUpgradePasswordWritesTheNewHash(): void + { + $user = $this->userManager->createUser('alice@example.test', 'old'); + $oldHash = $user->getPassword(); + + $this->repository->upgradePassword($user, 'a-new-hash'); + + self::assertSame('a-new-hash', $user->getPassword()); + + $reloaded = $this->repository->find($user->getId()); + self::assertNotNull($reloaded); + self::assertSame('a-new-hash', $reloaded->getPassword()); + self::assertNotSame($oldHash, $reloaded->getPassword()); + } + + public function testUpgradePasswordRejectsForeignUserType(): void + { + $foreignUser = new class implements PasswordAuthenticatedUserInterface { + public function getPassword(): ?string + { + return null; + } + }; + + $this->expectException(UnsupportedUserException::class); + + $this->repository->upgradePassword($foreignUser, 'irrelevant'); + } +} diff --git a/tests/Security/UserManagerTest.php b/tests/Security/UserManagerTest.php new file mode 100644 index 0000000..1a5b90f --- /dev/null +++ b/tests/Security/UserManagerTest.php @@ -0,0 +1,104 @@ +get(EntityManagerInterface::class)); + + $this->userManager = $container->get(UserManager::class); + $this->userRepository = $container->get(UserRepository::class); + $this->passwordHasher = $container->get(UserPasswordHasherInterface::class); + } + + public function testCreatesAndPersistsUserWithHashedPassword(): void + { + $user = $this->userManager->createUser('alice@example.test', 'secret'); + + self::assertNotNull($user->getId()); + self::assertSame('alice@example.test', $user->getEmail()); + self::assertSame(['ROLE_USER'], $user->getRoles()); + self::assertNotSame('secret', $user->getPassword(), 'Password must be hashed.'); + self::assertTrue( + $this->passwordHasher->isPasswordValid($user, 'secret'), + 'Hashed password must verify against the original plain text.', + ); + self::assertSame($user->getId(), $this->userRepository->findOneBy(['email' => 'alice@example.test'])?->getId()); + } + + public function testCreateUserStoresExtraRoles(): void + { + $user = $this->userManager->createUser('admin@example.test', 'secret', ['ROLE_ADMIN']); + + self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], $user->getRoles()); + } + + public function testCreateUserRejectsDuplicateEmail(): void + { + $this->userManager->createUser('alice@example.test', 'secret'); + + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('alice@example.test'); + + $this->userManager->createUser('alice@example.test', 'other'); + } + + public function testCreateUserRejectsEmptyPassword(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Password must not be empty.'); + + $this->userManager->createUser('alice@example.test', ''); + } + + public function testChangePasswordReplacesTheHash(): void + { + $user = $this->userManager->createUser('alice@example.test', 'old'); + $oldHash = $user->getPassword(); + + $updated = $this->userManager->changePassword('alice@example.test', 'new'); + + self::assertSame($user->getId(), $updated->getId()); + self::assertNotSame($oldHash, $updated->getPassword()); + self::assertTrue($this->passwordHasher->isPasswordValid($updated, 'new')); + self::assertFalse($this->passwordHasher->isPasswordValid($updated, 'old')); + } + + public function testChangePasswordFailsWhenUserMissing(): void + { + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('nobody@example.test'); + + $this->userManager->changePassword('nobody@example.test', 'whatever'); + } + + public function testChangePasswordRejectsEmptyPassword(): void + { + $this->userManager->createUser('alice@example.test', 'secret'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Password must not be empty.'); + + $this->userManager->changePassword('alice@example.test', ''); + } +} diff --git a/tests/Support/ResetsDatabaseSchemaTrait.php b/tests/Support/ResetsDatabaseSchemaTrait.php new file mode 100644 index 0000000..4e9619a --- /dev/null +++ b/tests/Support/ResetsDatabaseSchemaTrait.php @@ -0,0 +1,34 @@ +getMetadataFactory()->getAllMetadata(); + $schemaTool->dropDatabase(); + $schemaTool->createSchema($metadata); + } +} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index d7804b2..b69723a 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -33,6 +33,15 @@ tag: badge_default: "snart" title_default: "Kommer snart" +security: + login: + title: "Log ind – %brand%" + eyebrow: "Log ind" + heading: "Velkommen tilbage" + email_label: "E-mail" + password_label: "Adgangskode" + submit: "Log ind" + frontpage: title: "%brand% – forhåndsvisning" hero: From 349e098994810a16fddaf6e68b8634a7bb0a05be Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 19:25:06 +0200 Subject: [PATCH 02/14] fix(ci): grant db user access to db_test* via mariadb init SQL PHPUnit's test environment uses Symfony's dbname_suffix to talk to a separate test database (db_test, optionally db_test_paratest_N). The itkdev/mariadb image only grants MYSQL_USER on MYSQL_DATABASE, so the test runner hits 'Access denied for user db@% to database db_test' on a fresh container. Mount .docker/mariadb/init/ as /docker-entrypoint-initdb.d/ so the included GRANT runs on first container initialisation. The wildcard is escaped as db\_test% so it only matches db_test... not unrelated names like dbXtest. Local devs with an already-initialised mariadb volume can either recreate the container (task compose -- down -v && task site-install) or apply the grant once manually. Co-Authored-By: Claude Opus 4.7 (1M context) --- .docker/mariadb/init/01-grant-test-databases.sql | 14 ++++++++++++++ docker-compose.yml | 5 +++++ 2 files changed, 19 insertions(+) create mode 100644 .docker/mariadb/init/01-grant-test-databases.sql diff --git a/.docker/mariadb/init/01-grant-test-databases.sql b/.docker/mariadb/init/01-grant-test-databases.sql new file mode 100644 index 0000000..c21040a --- /dev/null +++ b/.docker/mariadb/init/01-grant-test-databases.sql @@ -0,0 +1,14 @@ +-- Grant the application user access to the per-environment test databases +-- Symfony's `when@test` doctrine config appends `_test` (plus an optional +-- ParaTest suffix) to the configured database name. The MariaDB image only +-- grants MYSQL_USER on MYSQL_DATABASE by default, so without this the +-- test suite fails with "Access denied for user 'db'@'%' to database +-- 'db_test'". The `\_test` escapes the SQL wildcard so the grant matches +-- `db_test`, `db_test_paratest_1`, etc. — but not unrelated names like +-- `dbXtest`. +-- +-- This file is mounted into `/docker-entrypoint-initdb.d/` and runs once +-- when the container's data volume is first initialised. + +GRANT ALL PRIVILEGES ON `db\_test%`.* TO `db`@`%`; +FLUSH PRIVILEGES; diff --git a/docker-compose.yml b/docker-compose.yml index 4c8f69a..178a1d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,11 @@ services: - MYSQL_PASSWORD=db - MYSQL_DATABASE=db #- ENCRYPT=1 # Uncomment to enable database encryption. + volumes: + # Grant the application user access to the `db_test*` databases + # Symfony creates in the test environment. See the file for + # details. + - ./.docker/mariadb/init:/docker-entrypoint-initdb.d:ro phpfpm: image: itkdev/php8.4-fpm:latest From 9523285ade3f8d3f1190bb8b58806b0ccb159063 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 19:29:53 +0200 Subject: [PATCH 03/14] chore(taskfile): self-heal test DB privileges before running PHPUnit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds task db-prepare-test which re-applies the GRANT init SQL and ensures db_test exists. test and test-coverage depend on it so any local dev whose mariadb data volume predates the new /docker-entrypoint-initdb.d mount can run tests without first recreating the container. The grant is idempotent and only touches privileges — no data is read or written. Co-Authored-By: Claude Opus 4.7 (1M context) --- Taskfile.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index 700ad33..e12f655 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -91,15 +91,27 @@ tasks: # ---- Tests ------------------------------------------------------------ + db-prepare-test: + desc: 'Ensure the test database exists and the app user can write to it (idempotent).' + cmds: + # Re-apply the init SQL on every invocation so local devs whose + # mariadb data volume predates the init mount also pick the grant up. + # Touches privileges only — no row data is read or written. + - cat .docker/mariadb/init/01-grant-test-databases.sql | task compose-exec -- mariadb mariadb -uroot -ppassword + - task console -- doctrine:database:create --if-not-exists --env=test + silent: true + test: desc: 'Run the PHPUnit test suite (no coverage).' cmds: + - task: db-prepare-test - task compose-exec -- phpfpm vendor/bin/phpunit silent: true test-coverage: desc: 'Run PHPUnit with coverage and enforce the 100% gate.' cmds: + - task: db-prepare-test - task compose -- exec -e XDEBUG_MODE=coverage phpfpm vendor/bin/phpunit --coverage-clover=coverage/clover.xml - task compose-exec -- phpfpm vendor/bin/coverage-check coverage/clover.xml 100 silent: true From e2c8c80ddeb01eaf70521e6ca1f3bf816526ce74 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 19:33:29 +0200 Subject: [PATCH 04/14] fix(ci): create db_test in the mariadb init SQL too The earlier init script only granted privileges on db_test* but never created the database. CI runs vendor/bin/phpunit directly (not task test), so the db-prepare-test target couldn't backfill the CREATE either, and the Tests workflow failed with 'Unknown database db_test'. Folds CREATE DATABASE IF NOT EXISTS db_test into the init SQL so both fresh CI containers and the local db-prepare-test target reach the same state. The doctrine:database:create call in db-prepare-test is now redundant and removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mariadb/init/01-grant-test-databases.sql | 26 ++++++++++++------- Taskfile.yml | 6 ++--- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.docker/mariadb/init/01-grant-test-databases.sql b/.docker/mariadb/init/01-grant-test-databases.sql index c21040a..1763ae7 100644 --- a/.docker/mariadb/init/01-grant-test-databases.sql +++ b/.docker/mariadb/init/01-grant-test-databases.sql @@ -1,14 +1,20 @@ --- Grant the application user access to the per-environment test databases --- Symfony's `when@test` doctrine config appends `_test` (plus an optional --- ParaTest suffix) to the configured database name. The MariaDB image only --- grants MYSQL_USER on MYSQL_DATABASE by default, so without this the --- test suite fails with "Access denied for user 'db'@'%' to database --- 'db_test'". The `\_test` escapes the SQL wildcard so the grant matches --- `db_test`, `db_test_paratest_1`, etc. — but not unrelated names like --- `dbXtest`. +-- Create the test database used by PHPUnit and grant the application +-- user access to it (plus any future ParaTest-suffixed siblings). -- --- This file is mounted into `/docker-entrypoint-initdb.d/` and runs once --- when the container's data volume is first initialised. +-- Symfony's `when@test` doctrine config appends `_test` to the configured +-- database name, and the MariaDB image only creates MYSQL_DATABASE and +-- grants MYSQL_USER on it. Without this file the test suite fails with +-- either "Unknown database 'db_test'" (database absent) or "Access +-- denied for user 'db'@'%'" (database present but no grant). +-- +-- The `\_test` escape on the GRANT pattern matches `db_test`, +-- `db_test_paratest_1`, etc. — but not unrelated names like `dbXtest`. +-- +-- This file is mounted into `/docker-entrypoint-initdb.d/` and runs +-- once when the container's data volume is first initialised. The +-- `task db-prepare-test` target re-applies the same logic for local +-- devs whose volume predates the mount. +CREATE DATABASE IF NOT EXISTS `db_test`; GRANT ALL PRIVILEGES ON `db\_test%`.* TO `db`@`%`; FLUSH PRIVILEGES; diff --git a/Taskfile.yml b/Taskfile.yml index e12f655..f417bdb 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -95,10 +95,10 @@ tasks: desc: 'Ensure the test database exists and the app user can write to it (idempotent).' cmds: # Re-apply the init SQL on every invocation so local devs whose - # mariadb data volume predates the init mount also pick the grant up. - # Touches privileges only — no row data is read or written. + # mariadb data volume predates the init mount also pick up the + # CREATE DATABASE + GRANT. The script is IF-NOT-EXISTS / additive + # only — no row data is read or written. - cat .docker/mariadb/init/01-grant-test-databases.sql | task compose-exec -- mariadb mariadb -uroot -ppassword - - task console -- doctrine:database:create --if-not-exists --env=test silent: true test: From 4f88b53882ec7e40c705f7743f9515f19dd5bc38 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 20:07:56 +0200 Subject: [PATCH 05/14] chore(taskfile): apply migrations as part of site-install Adds doctrine:migrations:migrate as the final step of site-install so a fresh check-out's database schema lands automatically. Drops the manual 'Apply the database schema' snippet from the README's user-creation section since the schema is now in place by the time anyone reaches it. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +++--- Taskfile.yml | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1bc9cb0..8aaed9a 100644 --- a/README.md +++ b/README.md @@ -147,10 +147,10 @@ URL is printed by the start task). ### Creating the first user -```sh -# Apply the database schema -task console -- doctrine:migrations:migrate -n +`task site-install` applies the database schema, so by the time you +get here the `user` table already exists. +```sh # Option A — load the local-dev fixtures (alice + bob, password `password`) task console -- doctrine:fixtures:load -n diff --git a/Taskfile.yml b/Taskfile.yml index f417bdb..7e5ebb2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -36,11 +36,12 @@ tasks: # ---- Lifecycle -------------------------------------------------------- site-install: - desc: 'Pull images, start the stack, and install Composer dependencies.' + desc: 'Pull images, start the stack, install Composer dependencies, and apply database migrations.' cmds: - task compose -- pull - task compose -- up --detach --remove-orphans --wait - task composer-install + - task console -- doctrine:migrations:migrate --no-interaction silent: true # ---- Composer --------------------------------------------------------- From 6b712de2f283d9fe9efe565f5868da14b31ac293 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 20:08:17 +0200 Subject: [PATCH 06/14] docs: trim the redundant site-install note from user-creation block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Creating the first user' section doesn't need to remind readers that site-install ran migrations — that's documented in the section above. Removes the explanatory paragraph and leaves just the commands. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 8aaed9a..ee3501c 100644 --- a/README.md +++ b/README.md @@ -147,9 +147,6 @@ URL is printed by the start task). ### Creating the first user -`task site-install` applies the database schema, so by the time you -get here the `user` table already exists. - ```sh # Option A — load the local-dev fixtures (alice + bob, password `password`) task console -- doctrine:fixtures:load -n From 46e417c546e3596b4231a4bfffd04b57b68e9d2c Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Thu, 11 Jun 2026 20:20:49 +0200 Subject: [PATCH 07/14] style: drop vertical phpdoc alignment; shorten @param/@return blurbs @Symfony enables phpdoc_align with align=vertical, which padded @param and @return so columns lined up and pushed descriptions onto extra wrapped lines. Override to align=left and rewrite the verbose blurbs in the user-auth code so each tag fits on one line. Net 46 lines removed across UserManager, the two console commands, SecurityController, UserFixtures, and DevTemplateMarkerNodeVisitor (the last one fell out of the same cs-fixer pass). Co-Authored-By: Claude Opus 4.7 (1M context) --- .php-cs-fixer.dist.php | 5 +++ src/Command/UserChangePasswordCommand.php | 16 +++----- src/Command/UserCreateCommand.php | 18 +++------ src/Controller/SecurityController.php | 32 +++++---------- src/DataFixtures/UserFixtures.php | 17 +++----- src/Security/UserManager.php | 48 +++++++---------------- src/Twig/DevTemplateMarkerNodeVisitor.php | 12 +++--- 7 files changed, 51 insertions(+), 97 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index c23b927..bb2e707 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -15,6 +15,11 @@ $config->setRules([ '@Symfony' => true, + // Override the Symfony default that vertically aligns @param / @return + // columns by padding names and descriptions with spaces. We keep tags + // left-aligned so descriptions don't get pushed into hard-to-read + // multi-line wraps. + 'phpdoc_align' => ['align' => 'left'], ]); return $config; diff --git a/src/Command/UserChangePasswordCommand.php b/src/Command/UserChangePasswordCommand.php index 614e5d0..3e6d49d 100644 --- a/src/Command/UserChangePasswordCommand.php +++ b/src/Command/UserChangePasswordCommand.php @@ -15,8 +15,8 @@ /** * Console command that updates an existing user's password. * + * Thin adapter over {@see UserManager::changePassword()}. * Usage: `task console -- app:user:change-password `. - * Delegates to {@see UserManager::changePassword()}. */ #[AsCommand( name: 'app:user:change-password', @@ -25,8 +25,7 @@ final class UserChangePasswordCommand extends Command { /** - * @param UserManager $userManager service that owns password hashing and - * persistence + * @param UserManager $userManager service that owns password rotation */ public function __construct(private readonly UserManager $userManager) { @@ -44,15 +43,12 @@ protected function configure(): void } /** - * Adapt console arguments to the {@see UserManager} call and render - * a success / error message. + * Adapt console arguments to the {@see UserManager} call. * - * @param InputInterface $input CLI arguments - * @param OutputInterface $output Symfony console output stream + * @param InputInterface $input CLI arguments + * @param OutputInterface $output console output stream * - * @return int Command::SUCCESS on a successful change, - * Command::FAILURE when the user is not found or the - * password is rejected + * @return int Command::SUCCESS on a successful change, Command::FAILURE otherwise */ protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/UserCreateCommand.php b/src/Command/UserCreateCommand.php index c0d7974..ba4de44 100644 --- a/src/Command/UserCreateCommand.php +++ b/src/Command/UserCreateCommand.php @@ -15,10 +15,8 @@ /** * Console command that creates a new application user. * + * Thin adapter over {@see UserManager::createUser()}. * Usage: `task console -- app:user:create `. - * Delegates to {@see UserManager::createUser()} for the actual - * persistence and hashing — the command itself just adapts CLI input - * and renders the outcome. */ #[AsCommand( name: 'app:user:create', @@ -27,8 +25,7 @@ final class UserCreateCommand extends Command { /** - * @param UserManager $userManager service that owns user creation, - * password hashing, and persistence + * @param UserManager $userManager service that owns user creation */ public function __construct(private readonly UserManager $userManager) { @@ -46,15 +43,12 @@ protected function configure(): void } /** - * Adapt console arguments to the {@see UserManager} call and render - * a success / error message. + * Adapt console arguments to the {@see UserManager} call. * - * @param InputInterface $input CLI arguments - * @param OutputInterface $output Symfony console output stream + * @param InputInterface $input CLI arguments + * @param OutputInterface $output console output stream * - * @return int Command::SUCCESS on creation, Command::FAILURE when - * {@see UserManager::createUser()} throws a domain or - * validation error + * @return int Command::SUCCESS on creation, Command::FAILURE on domain or validation error */ protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 3addc34..bfd3bba 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -13,31 +13,19 @@ /** * Routes for interactive authentication. * - * `GET/POST /login` renders the login form and surfaces the last - * authentication error and pre-filled username via Symfony Security's - * {@see AuthenticationUtils}. The actual credential check is performed - * by the `form_login` authenticator declared in `security.yaml`, not - * here — this controller stays in the "inject service → render" - * shape required by project conventions. - * - * `/logout` is wired declaratively in the firewall's `logout` block; - * the method body is never executed because Symfony intercepts the - * request and clears the session. + * `/login` renders the form and surfaces the previous error / last + * username via {@see AuthenticationUtils}. The credential check is + * performed by the `form_login` authenticator declared in + * `security.yaml`, not here. `/logout` is intercepted by the firewall. */ final class SecurityController extends AbstractController { /** * Render the login form. * - * @param AuthenticationUtils $authenticationUtils symfony Security helper - * providing the previous - * login error (if any) - * and the last entered - * username so the form - * can be re-rendered with - * user input preserved + * @param AuthenticationUtils $authenticationUtils Security helper for the previous error + last username * - * @return Response the rendered `security/login.html.twig` template + * @return Response the rendered login template */ #[Route(path: '/login', name: 'app_login', methods: ['GET', 'POST'])] public function login(AuthenticationUtils $authenticationUtils): Response @@ -51,11 +39,9 @@ public function login(AuthenticationUtils $authenticationUtils): Response /** * Placeholder action for the `app_logout` route. * - * The route exists so URL generation (`{{ path('app_logout') }}`) - * works in templates, but Symfony Security intercepts the request - * and handles session invalidation before this method is called. - * The unreachable body throws so that any accidental direct call - * fails loudly. + * The firewall intercepts the request and clears the session, so + * the body is unreachable in production. The throw guards against + * an accidental direct call. * * @throws \LogicException always — the firewall must intercept */ diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php index d9e536a..67e4dd7 100644 --- a/src/DataFixtures/UserFixtures.php +++ b/src/DataFixtures/UserFixtures.php @@ -11,19 +11,14 @@ /** * Seed two baseline users for local development. * - * `alice@example.test` and `bob@example.test` both get the same - * intentionally weak password (`password`) so they're convenient to - * paste into the login form. The fixture is excluded from prod by - * Doctrine's standard fixtures workflow (`bin/console - * doctrine:fixtures:load` is a dev/test command). + * `alice@example.test` and `bob@example.test`, both with the + * intentionally-weak password `password`, so they're easy to paste + * into the login form. */ final class UserFixtures extends Fixture { /** - * @param UserManager $userManager service that creates the persisted - * {@see \App\Entity\User} entities, - * keeping fixture code from duplicating - * the hashing wiring + * @param UserManager $userManager service that creates the persisted users */ public function __construct(private readonly UserManager $userManager) { @@ -32,9 +27,7 @@ public function __construct(private readonly UserManager $userManager) /** * Persist the two baseline users via {@see UserManager::createUser()}. * - * @param ObjectManager $manager unused — the {@see UserManager} - * flushes through its own injected - * entity manager + * @param ObjectManager $manager unused — UserManager flushes its own entity manager */ public function load(ObjectManager $manager): void { diff --git a/src/Security/UserManager.php b/src/Security/UserManager.php index 074a6da..25ca58e 100644 --- a/src/Security/UserManager.php +++ b/src/Security/UserManager.php @@ -10,29 +10,18 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; /** - * Create users and change passwords without leaking persistence and - * password-hashing wiring out into controllers or console commands. + * Create users and rotate their passwords. * - * Callers ({@see \App\Command\UserCreateCommand}, - * {@see \App\Command\UserChangePasswordCommand}, and any future signup - * flow) work with strings and get a persisted {@see User} back — the - * service hides the {@see EntityManagerInterface}, - * {@see UserRepository}, and {@see UserPasswordHasherInterface} - * collaborators. + * Hides the Doctrine + password-hasher wiring from callers + * (controllers, console commands, fixtures) so they work with plain + * strings and get a persisted {@see User} back. */ final class UserManager { /** - * @param EntityManagerInterface $entityManager doctrine entity manager - * used to persist and - * flush the {@see User} - * aggregate - * @param UserRepository $userRepository read-side lookup of - * users by email - * @param UserPasswordHasherInterface $passwordHasher Symfony Security - * password hasher, - * configured via - * `security.yaml`. + * @param EntityManagerInterface $entityManager Doctrine entity manager + * @param UserRepository $userRepository read-side lookup of users by email + * @param UserPasswordHasherInterface $passwordHasher Symfony Security hasher */ public function __construct( private readonly EntityManagerInterface $entityManager, @@ -44,20 +33,13 @@ public function __construct( /** * Create a new persisted user with a hashed password. * - * @param string $email the user's e-mail address; must be - * unique across the table - * @param string $plainPassword the password in clear-text — it is - * hashed before persistence and never - * stored as-is - * @param list $roles additional roles to grant; the - * framework guarantees `ROLE_USER` - * implicitly so callers should leave - * this empty for plain users + * @param string $email user e-mail; must be unique + * @param string $plainPassword clear-text password, hashed before persistence + * @param list $roles additional roles beyond the implicit `ROLE_USER` * * @return User the persisted user with an assigned id * - * @throws \DomainException when a user with the same e-mail - * already exists + * @throws \DomainException when a user with the same e-mail already exists * @throws \InvalidArgumentException when `$plainPassword` is empty */ public function createUser(string $email, string $plainPassword, array $roles = []): User @@ -84,14 +66,12 @@ public function createUser(string $email, string $plainPassword, array $roles = /** * Replace a user's password with a freshly hashed copy. * - * @param string $email the e-mail of the user whose password - * to change - * @param string $newPlainPassword the new password in clear-text; hashed - * before persistence + * @param string $email e-mail of the user to update + * @param string $newPlainPassword new clear-text password, hashed before persistence * * @return User the updated user * - * @throws \DomainException when no user with that e-mail exists + * @throws \DomainException when no user with that e-mail exists * @throws \InvalidArgumentException when `$newPlainPassword` is empty */ public function changePassword(string $email, string $newPlainPassword): User diff --git a/src/Twig/DevTemplateMarkerNodeVisitor.php b/src/Twig/DevTemplateMarkerNodeVisitor.php index 40aa315..d274713 100644 --- a/src/Twig/DevTemplateMarkerNodeVisitor.php +++ b/src/Twig/DevTemplateMarkerNodeVisitor.php @@ -37,8 +37,8 @@ public function enterNode(Node $node, Environment $env): Node * Wrap the template body (or its `body` block, for extending * templates) with begin and end marker TextNodes. * - * @param Node $node the node being left - * @param Environment $env the Twig environment (unused) + * @param Node $node the node being left + * @param Environment $env the Twig environment (unused) * * @return Node the original node, mutated in place when applicable */ @@ -77,9 +77,9 @@ public function leaveNode(Node $node, Environment $env): Node * `BlockNode`; we iterate to find the BlockNode and replace its * body with the wrapped sequence. * - * @param ModuleNode $node the extending module - * @param TextNode $prefix the opening marker - * @param TextNode $suffix the closing marker + * @param ModuleNode $node the extending module + * @param TextNode $prefix the opening marker + * @param TextNode $suffix the closing marker */ private function wrapExtendingBody(ModuleNode $node, TextNode $prefix, TextNode $suffix): void { @@ -111,7 +111,7 @@ public function getPriority(): int * Replace `$node`'s `body` child with a sequence: prefix, original * body, suffix. * - * @param Node $node the node whose body to wrap + * @param Node $node the node whose body to wrap * @param TextNode $prefix the opening marker * @param TextNode $suffix the closing marker */ From cedcaa75a28193447f19e1d1319195489aebdc8f Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Mon, 15 Jun 2026 12:47:25 +0200 Subject: [PATCH 08/14] refactor(test-db): bootstrap via Symfony console, no init SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #59: replace the hand-rolled mariadb init SQL (which created db_test and granted the db user on db_test%) with bin/console doctrine:database:create --env=test, the standard Symfony approach. The db user has grants scoped to the db database only, so .env.test overrides DATABASE_URL to connect as root against an explicit db_test. The when@test doctrine block (which appended the _test suffix) is removed — the URL now names the test database directly, so dev/test separation cannot be silently broken by a misread DATABASE_URL. - Delete .docker/mariadb/init/01-grant-test-databases.sql and the docker-compose volume mount that wired it into the container. - Replace task db-prepare-test with the Symfony console command (idempotent via --if-not-exists). - Add the same database-create step to the Tests CI workflow. --- .../mariadb/init/01-grant-test-databases.sql | 20 ------------------- .env.test | 2 ++ .github/workflows/tests.yaml | 3 +++ Taskfile.yml | 8 ++------ config/packages/doctrine.yaml | 6 ------ docker-compose.yml | 5 ----- 6 files changed, 7 insertions(+), 37 deletions(-) delete mode 100644 .docker/mariadb/init/01-grant-test-databases.sql diff --git a/.docker/mariadb/init/01-grant-test-databases.sql b/.docker/mariadb/init/01-grant-test-databases.sql deleted file mode 100644 index 1763ae7..0000000 --- a/.docker/mariadb/init/01-grant-test-databases.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Create the test database used by PHPUnit and grant the application --- user access to it (plus any future ParaTest-suffixed siblings). --- --- Symfony's `when@test` doctrine config appends `_test` to the configured --- database name, and the MariaDB image only creates MYSQL_DATABASE and --- grants MYSQL_USER on it. Without this file the test suite fails with --- either "Unknown database 'db_test'" (database absent) or "Access --- denied for user 'db'@'%'" (database present but no grant). --- --- The `\_test` escape on the GRANT pattern matches `db_test`, --- `db_test_paratest_1`, etc. — but not unrelated names like `dbXtest`. --- --- This file is mounted into `/docker-entrypoint-initdb.d/` and runs --- once when the container's data volume is first initialised. The --- `task db-prepare-test` target re-applies the same logic for local --- devs whose volume predates the mount. - -CREATE DATABASE IF NOT EXISTS `db_test`; -GRANT ALL PRIVILEGES ON `db\_test%`.* TO `db`@`%`; -FLUSH PRIVILEGES; diff --git a/.env.test b/.env.test index 64bd111..c450516 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,5 @@ # define your env variables for the test env here KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' + +DATABASE_URL="mysql://root:password@mariadb:3306/db_test?serverVersion=10.11.16-MariaDB&charset=utf8mb4" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7cce7b8..6a2d104 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -22,6 +22,9 @@ jobs: - name: Build Tailwind CSS run: docker compose run --rm phpfpm bin/console tailwind:build + - name: Create the test database + run: docker compose run --rm phpfpm bin/console --env=test doctrine:database:create --if-not-exists --no-interaction + - name: Run PHPUnit with coverage run: docker compose run -e XDEBUG_MODE=coverage --rm phpfpm vendor/bin/phpunit --coverage-clover=coverage/clover.xml diff --git a/Taskfile.yml b/Taskfile.yml index 7e5ebb2..9adf432 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -93,13 +93,9 @@ tasks: # ---- Tests ------------------------------------------------------------ db-prepare-test: - desc: 'Ensure the test database exists and the app user can write to it (idempotent).' + desc: 'Ensure the test database exists (idempotent).' cmds: - # Re-apply the init SQL on every invocation so local devs whose - # mariadb data volume predates the init mount also pick up the - # CREATE DATABASE + GRANT. The script is IF-NOT-EXISTS / additive - # only — no row data is read or written. - - cat .docker/mariadb/init/01-grant-test-databases.sql | task compose-exec -- mariadb mariadb -uroot -ppassword + - task console -- --env=test doctrine:database:create --if-not-exists --no-interaction silent: true test: diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index c196bab..63ed69a 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -14,12 +14,6 @@ doctrine: prefix: 'App\Entity' alias: App -when@test: - doctrine: - dbal: - # "TEST_TOKEN" is typically set by ParaTest - dbname_suffix: '_test%env(default::TEST_TOKEN)%' - when@prod: doctrine: orm: diff --git a/docker-compose.yml b/docker-compose.yml index 178a1d2..4c8f69a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,11 +25,6 @@ services: - MYSQL_PASSWORD=db - MYSQL_DATABASE=db #- ENCRYPT=1 # Uncomment to enable database encryption. - volumes: - # Grant the application user access to the `db_test*` databases - # Symfony creates in the test environment. See the file for - # details. - - ./.docker/mariadb/init:/docker-entrypoint-initdb.d:ro phpfpm: image: itkdev/php8.4-fpm:latest From 61c91f467ed3d48c61b9d6ff064d87fc6c7e5a94 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Mon, 15 Jun 2026 13:08:19 +0200 Subject: [PATCH 09/14] refactor(migration): port initial user-table migration to Schema API Address PR #59 review feedback: tuj noted that os2display has adopted Doctrine's Schema tool API for migrations so they stay portable across any database Doctrine supports, enforced via a NoAddSqlInMigration PHPStan rule. Mirror that convention here. The previous body called $this->addSql() with a MariaDB-specific CREATE TABLE string. The new body uses $schema->createTable() with typed columns, the primary key, the unique index, and the utf8mb4 table option so the resulting DDL is byte-identical on MariaDB while remaining portable. Verified by dropping db_test, running doctrine:migrations:migrate, then doctrine:migrations:diff to confirm no schema delta vs the User entity metadata. --- migrations/Version20260611124347.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/migrations/Version20260611124347.php b/migrations/Version20260611124347.php index 37fa0a1..4e4835c 100644 --- a/migrations/Version20260611124347.php +++ b/migrations/Version20260611124347.php @@ -5,10 +5,14 @@ namespace DoctrineMigrations; use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; /** * Initial `user` table for application authentication (#2). + * + * Uses Doctrine's Schema tool API (no raw `addSql`) so the migration + * stays portable across any database Doctrine supports. */ final class Version20260611124347 extends AbstractMigration { @@ -19,11 +23,18 @@ public function getDescription(): string public function up(Schema $schema): void { - $this->addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $table = $schema->createTable('user'); + $table->addColumn('id', Types::INTEGER, ['notnull' => true, 'autoincrement' => true]); + $table->addColumn('email', Types::STRING, ['length' => 180, 'notnull' => true]); + $table->addColumn('roles', Types::JSON, ['notnull' => true]); + $table->addColumn('password', Types::STRING, ['length' => 255, 'notnull' => true]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['email'], 'UNIQ_IDENTIFIER_EMAIL'); + $table->addOption('charset', 'utf8mb4'); } public function down(Schema $schema): void { - $this->addSql('DROP TABLE `user`'); + $schema->dropTable('user'); } } From 4d8cffd58bd2dd93b238b521fa0c051d66cc0a05 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Mon, 15 Jun 2026 13:10:35 +0200 Subject: [PATCH 10/14] docs(commands): drop Taskfile usage line from user-command PHPDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #59 review feedback: tuj flagged that the Usage line in UserChangePasswordCommand duplicates information already carried by the AsCommand attribute and addArgument descriptions. Strip the same line from UserCreateCommand for consistency. The README's "Creating the first user" section keeps its `task console -- app:user:...` examples — those are human-facing setup docs, not the per-class documentation tuj objected to. --- src/Command/UserChangePasswordCommand.php | 1 - src/Command/UserCreateCommand.php | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Command/UserChangePasswordCommand.php b/src/Command/UserChangePasswordCommand.php index 3e6d49d..4c9cbec 100644 --- a/src/Command/UserChangePasswordCommand.php +++ b/src/Command/UserChangePasswordCommand.php @@ -16,7 +16,6 @@ * Console command that updates an existing user's password. * * Thin adapter over {@see UserManager::changePassword()}. - * Usage: `task console -- app:user:change-password `. */ #[AsCommand( name: 'app:user:change-password', diff --git a/src/Command/UserCreateCommand.php b/src/Command/UserCreateCommand.php index ba4de44..da171d6 100644 --- a/src/Command/UserCreateCommand.php +++ b/src/Command/UserCreateCommand.php @@ -16,7 +16,6 @@ * Console command that creates a new application user. * * Thin adapter over {@see UserManager::createUser()}. - * Usage: `task console -- app:user:create `. */ #[AsCommand( name: 'app:user:create', From 7ef32d3f5a5dbb78bb1556c45ffb149a7d5535bf Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Mon, 15 Jun 2026 13:14:36 +0200 Subject: [PATCH 11/14] style(php-cs-fixer): drop local phpdoc_align override Address PR #59 review feedback: tuj noted the override should live upstream in itk-dev/devops_itkdev-docker so the style aligns across projects, not as a per-project delta. Restore .php-cs-fixer.dist.php to match the template byte-for-byte. Follow-up needed: existing PHPDocs are left-aligned (from commit 46e417c) but the @Symfony preset's default phpdoc_align is `vertical`. The CI PHP CS check will fail until either an upstream PR lands the rule or the local files are reflowed via task coding-standards-php-apply. --- .php-cs-fixer.dist.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index bb2e707..c23b927 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -15,11 +15,6 @@ $config->setRules([ '@Symfony' => true, - // Override the Symfony default that vertically aligns @param / @return - // columns by padding names and descriptions with spaces. We keep tags - // left-aligned so descriptions don't get pushed into hard-to-read - // multi-line wraps. - 'phpdoc_align' => ['align' => 'left'], ]); return $config; From 6b0a0f179e7abc5a8227ff69776826b14b6f3555 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Mon, 15 Jun 2026 14:09:12 +0200 Subject: [PATCH 12/14] test: split suite into unit + integration with DAMA isolation Address PR #59 review item 6: tuj pointed at the Symfony docs section on resetting the database between tests, plus the economics phpunit.xml.dist split. Adopt both. - composer require --dev dama/doctrine-test-bundle:^8.6 for transactional per-test isolation. The bundle wraps every test in a DBAL transaction and rolls back at tearDown, so per-test schema rebuilds are no longer needed. - Two PHPUnit testsuites: unit (tests/Unit, 11 cases, no kernel) and integration (tests/Integration, 21 cases, full kernel + DB). - tests/bootstrap.php builds the schema once from ORM metadata via Doctrine SchemaTool. Guarded by TESTS_SKIP_SCHEMA=1 so task test-unit can run against a torn-down database. - Remove ResetsDatabaseSchemaTrait and its 6 setUp() invocations. Empty bootstrap state plus DAMA rollback gives the same isolation guarantee at no per-test DDL cost. - New Taskfile targets: test-unit and test-integration. The existing task test continues to run both suites in one PHPUnit invocation, so the 100% coverage gate is unchanged. Verified locally: task test (32/32), task test-unit against a dropped db_test (11/11), task test-integration (21/21), task test twice back-to-back, task test-coverage (100%). PHP CS check fails only on the 4 files carried over from commit 7ef32d3 (phpdoc_align revert); the moved/edited files all pass. --- CHANGELOG.md | 6 ++ Taskfile.yml | 13 ++++ composer.json | 1 + composer.lock | 71 ++++++++++++++++++- config/bundles.php | 1 + config/reference.php | 7 ++ phpunit.dist.xml | 8 ++- symfony.lock | 3 + .../Command/UserChangePasswordCommandTest.php | 7 +- .../Command/UserCreateCommandTest.php | 7 +- .../Controller/FrontpageControllerTest.php | 2 +- .../Controller/SecurityControllerTest.php | 7 +- .../DataFixtures/UserFixturesTest.php | 6 +- .../Repository/UserRepositoryTest.php | 7 +- .../Security/UserManagerTest.php | 8 +-- tests/{ => Integration}/SmokeTest.php | 2 +- tests/Support/ResetsDatabaseSchemaTrait.php | 34 --------- tests/{ => Unit}/KernelTest.php | 2 +- .../Twig/DevTemplateMarkerExtensionTest.php | 2 +- .../Twig/DevTemplateMarkerNodeVisitorTest.php | 2 +- tests/bootstrap.php | 18 +++++ 21 files changed, 136 insertions(+), 78 deletions(-) rename tests/{ => Integration}/Command/UserChangePasswordCommandTest.php (86%) rename tests/{ => Integration}/Command/UserCreateCommandTest.php (86%) rename tests/{ => Integration}/Controller/FrontpageControllerTest.php (95%) rename tests/{ => Integration}/Controller/SecurityControllerTest.php (93%) rename tests/{ => Integration}/DataFixtures/UserFixturesTest.php (88%) rename tests/{ => Integration}/Repository/UserRepositoryTest.php (90%) rename tests/{ => Integration}/Security/UserManagerTest.php (93%) rename tests/{ => Integration}/SmokeTest.php (91%) delete mode 100644 tests/Support/ResetsDatabaseSchemaTrait.php rename tests/{ => Unit}/KernelTest.php (96%) rename tests/{ => Unit}/Twig/DevTemplateMarkerExtensionTest.php (96%) rename tests/{ => Unit}/Twig/DevTemplateMarkerNodeVisitorTest.php (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4b5f7..ef4831c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 console commands `app:user:create` and `app:user:change-password`, and end-to-end functional + unit tests ([#2](https://github.com/itk-dev/ai-lib/issues/2)). +- PHPUnit suite split into `unit` (no database) and `integration` (full + kernel) testsuites under `tests/Unit/` and `tests/Integration/`, with + transactional database isolation per integration test via + `dama/doctrine-test-bundle`. Schema is built once from ORM metadata in + `tests/bootstrap.php`. `task test-unit` and `task test-integration` + expose the suites individually. - Site chrome (header with brand + nav, footer) in `templates/base.html.twig`, with the Fraunces/Geist font stack preloaded from Google Fonts. diff --git a/Taskfile.yml b/Taskfile.yml index 9adf432..8978691 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -105,6 +105,19 @@ tasks: - task compose-exec -- phpfpm vendor/bin/phpunit silent: true + test-unit: + desc: 'Run only the unit suite (no database).' + cmds: + - task compose -- exec --no-TTY -e TESTS_SKIP_SCHEMA=1 phpfpm vendor/bin/phpunit --testsuite=unit + silent: true + + test-integration: + desc: 'Run only the integration suite.' + cmds: + - task: db-prepare-test + - task compose-exec -- phpfpm vendor/bin/phpunit --testsuite=integration + silent: true + test-coverage: desc: 'Run PHPUnit with coverage and enforce the 100% gate.' cmds: diff --git a/composer.json b/composer.json index aa0ffec..f77ae05 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "twig/twig": "^2.12 || ^3.0" }, "require-dev": { + "dama/doctrine-test-bundle": "^8.0", "doctrine/doctrine-fixtures-bundle": "^4.3", "ergebnis/composer-normalize": "^2.52", "friendsofphp/php-cs-fixer": "^3.95.5", diff --git a/composer.lock b/composer.lock index 3bead4f..d01e9c3 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": "515575e1ba1d454616c991cd865c269a", + "content-hash": "1f726a6cc8d55f7b7bc4987f8298e1a6", "packages": [ { "name": "composer/semver", @@ -6089,6 +6089,75 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "dama/doctrine-test-bundle", + "version": "v8.6.0", + "source": { + "type": "git", + "url": "https://github.com/dmaicher/doctrine-test-bundle.git", + "reference": "f7e3487643e685432f7e27c50cac64e9f8c515a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/f7e3487643e685432f7e27c50cac64e9f8c515a4", + "reference": "f7e3487643e685432f7e27c50cac64e9f8c515a4", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.3 || ^4.0", + "doctrine/doctrine-bundle": "^2.11.0 || ^3.0", + "php": ">= 8.2", + "psr/cache": "^2.0 || ^3.0", + "symfony/cache": "^6.4 || ^7.3 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.3 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<11.0" + }, + "require-dev": { + "behat/behat": "^3.0", + "friendsofphp/php-cs-fixer": "^3.27", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.41|| ^12.3.14", + "symfony/dotenv": "^6.4 || ^7.3 || ^8.0", + "symfony/process": "^6.4 || ^7.3 || ^8.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "DAMA\\DoctrineTestBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David Maicher", + "email": "mail@dmaicher.de" + } + ], + "description": "Symfony bundle to isolate doctrine database tests and improve test performance", + "keywords": [ + "doctrine", + "isolation", + "performance", + "symfony", + "testing", + "tests" + ], + "support": { + "issues": "https://github.com/dmaicher/doctrine-test-bundle/issues", + "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.6.0" + }, + "time": "2026-01-21T07:39:44+00:00" + }, { "name": "doctrine/data-fixtures", "version": "2.2.1", diff --git a/config/bundles.php b/config/bundles.php index 744b525..fee9719 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -12,4 +12,5 @@ Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], ]; diff --git a/config/reference.php b/config/reference.php index 40bcddb..c1eabf2 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1327,6 +1327,12 @@ * generate_final_classes?: bool|Param, // Default: true * generate_final_entities?: bool|Param, // Default: false * } + * @psalm-type DamaDoctrineTestConfig = array{ + * enable_static_connection?: mixed, // Default: true + * enable_static_meta_data_cache?: bool|Param, // Default: true + * enable_static_query_cache?: bool|Param, // Default: true + * connection_keys?: list, + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1382,6 +1388,7 @@ * doctrine?: DoctrineConfig, * doctrine_migrations?: DoctrineMigrationsConfig, * security?: SecurityConfig, + * dama_doctrine_test?: DamaDoctrineTestConfig, * }, * ... - - tests + + tests/Unit + + + tests/Integration @@ -44,5 +47,6 @@ + diff --git a/symfony.lock b/symfony.lock index 976c559..427fa08 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,4 +1,7 @@ { + "dama/doctrine-test-bundle": { + "version": "v8.6.0" + }, "doctrine/deprecations": { "version": "1.1", "recipe": { diff --git a/tests/Command/UserChangePasswordCommandTest.php b/tests/Integration/Command/UserChangePasswordCommandTest.php similarity index 86% rename from tests/Command/UserChangePasswordCommandTest.php rename to tests/Integration/Command/UserChangePasswordCommandTest.php index 8bf1f3a..be08bd2 100644 --- a/tests/Command/UserChangePasswordCommandTest.php +++ b/tests/Integration/Command/UserChangePasswordCommandTest.php @@ -2,19 +2,15 @@ declare(strict_types=1); -namespace App\Tests\Command; +namespace App\Tests\Integration\Command; use App\Security\UserManager; -use App\Tests\Support\ResetsDatabaseSchemaTrait; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Tester\CommandTester; final class UserChangePasswordCommandTest extends KernelTestCase { - use ResetsDatabaseSchemaTrait; - private CommandTester $tester; private UserManager $userManager; @@ -22,7 +18,6 @@ protected function setUp(): void { self::bootKernel(); $container = self::getContainer(); - self::resetSchema($container->get(EntityManagerInterface::class)); $this->userManager = $container->get(UserManager::class); diff --git a/tests/Command/UserCreateCommandTest.php b/tests/Integration/Command/UserCreateCommandTest.php similarity index 86% rename from tests/Command/UserCreateCommandTest.php rename to tests/Integration/Command/UserCreateCommandTest.php index ea39ac5..5fc4f78 100644 --- a/tests/Command/UserCreateCommandTest.php +++ b/tests/Integration/Command/UserCreateCommandTest.php @@ -2,19 +2,15 @@ declare(strict_types=1); -namespace App\Tests\Command; +namespace App\Tests\Integration\Command; use App\Security\UserManager; -use App\Tests\Support\ResetsDatabaseSchemaTrait; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Tester\CommandTester; final class UserCreateCommandTest extends KernelTestCase { - use ResetsDatabaseSchemaTrait; - private CommandTester $tester; private UserManager $userManager; @@ -22,7 +18,6 @@ protected function setUp(): void { self::bootKernel(); $container = self::getContainer(); - self::resetSchema($container->get(EntityManagerInterface::class)); $this->userManager = $container->get(UserManager::class); diff --git a/tests/Controller/FrontpageControllerTest.php b/tests/Integration/Controller/FrontpageControllerTest.php similarity index 95% rename from tests/Controller/FrontpageControllerTest.php rename to tests/Integration/Controller/FrontpageControllerTest.php index 25d57b5..39d089a 100644 --- a/tests/Controller/FrontpageControllerTest.php +++ b/tests/Integration/Controller/FrontpageControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\Controller; +namespace App\Tests\Integration\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; diff --git a/tests/Controller/SecurityControllerTest.php b/tests/Integration/Controller/SecurityControllerTest.php similarity index 93% rename from tests/Controller/SecurityControllerTest.php rename to tests/Integration/Controller/SecurityControllerTest.php index 841821f..8b5d172 100644 --- a/tests/Controller/SecurityControllerTest.php +++ b/tests/Integration/Controller/SecurityControllerTest.php @@ -2,13 +2,11 @@ declare(strict_types=1); -namespace App\Tests\Controller; +namespace App\Tests\Integration\Controller; use App\Controller\SecurityController; use App\Entity\User; use App\Security\UserManager; -use App\Tests\Support\ResetsDatabaseSchemaTrait; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -18,15 +16,12 @@ */ final class SecurityControllerTest extends WebTestCase { - use ResetsDatabaseSchemaTrait; - private KernelBrowser $client; protected function setUp(): void { $this->client = self::createClient(); $container = self::getContainer(); - self::resetSchema($container->get(EntityManagerInterface::class)); $container->get(UserManager::class) ->createUser('alice@example.test', 'secret'); diff --git a/tests/DataFixtures/UserFixturesTest.php b/tests/Integration/DataFixtures/UserFixturesTest.php similarity index 88% rename from tests/DataFixtures/UserFixturesTest.php rename to tests/Integration/DataFixtures/UserFixturesTest.php index 9190abd..ea884df 100644 --- a/tests/DataFixtures/UserFixturesTest.php +++ b/tests/Integration/DataFixtures/UserFixturesTest.php @@ -2,12 +2,11 @@ declare(strict_types=1); -namespace App\Tests\DataFixtures; +namespace App\Tests\Integration\DataFixtures; use App\DataFixtures\UserFixtures; use App\Entity\User; use App\Repository\UserRepository; -use App\Tests\Support\ResetsDatabaseSchemaTrait; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -20,14 +19,11 @@ */ final class UserFixturesTest extends KernelTestCase { - use ResetsDatabaseSchemaTrait; - public function testLoadsAliceAndBob(): void { self::bootKernel(); $container = self::getContainer(); $em = $container->get(EntityManagerInterface::class); - self::resetSchema($em); $container->get(UserFixtures::class)->load($em); diff --git a/tests/Repository/UserRepositoryTest.php b/tests/Integration/Repository/UserRepositoryTest.php similarity index 90% rename from tests/Repository/UserRepositoryTest.php rename to tests/Integration/Repository/UserRepositoryTest.php index 06255de..f47f50c 100644 --- a/tests/Repository/UserRepositoryTest.php +++ b/tests/Integration/Repository/UserRepositoryTest.php @@ -2,12 +2,10 @@ declare(strict_types=1); -namespace App\Tests\Repository; +namespace App\Tests\Integration\Repository; use App\Repository\UserRepository; use App\Security\UserManager; -use App\Tests\Support\ResetsDatabaseSchemaTrait; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -23,8 +21,6 @@ */ final class UserRepositoryTest extends KernelTestCase { - use ResetsDatabaseSchemaTrait; - private UserRepository $repository; private UserManager $userManager; @@ -32,7 +28,6 @@ protected function setUp(): void { self::bootKernel(); $container = self::getContainer(); - self::resetSchema($container->get(EntityManagerInterface::class)); $this->repository = $container->get(UserRepository::class); $this->userManager = $container->get(UserManager::class); diff --git a/tests/Security/UserManagerTest.php b/tests/Integration/Security/UserManagerTest.php similarity index 93% rename from tests/Security/UserManagerTest.php rename to tests/Integration/Security/UserManagerTest.php index 1a5b90f..ecc9e4e 100644 --- a/tests/Security/UserManagerTest.php +++ b/tests/Integration/Security/UserManagerTest.php @@ -2,19 +2,15 @@ declare(strict_types=1); -namespace App\Tests\Security; +namespace App\Tests\Integration\Security; use App\Repository\UserRepository; use App\Security\UserManager; -use App\Tests\Support\ResetsDatabaseSchemaTrait; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; final class UserManagerTest extends KernelTestCase { - use ResetsDatabaseSchemaTrait; - private UserManager $userManager; private UserRepository $userRepository; private UserPasswordHasherInterface $passwordHasher; @@ -24,8 +20,6 @@ protected function setUp(): void self::bootKernel(); $container = self::getContainer(); - self::resetSchema($container->get(EntityManagerInterface::class)); - $this->userManager = $container->get(UserManager::class); $this->userRepository = $container->get(UserRepository::class); $this->passwordHasher = $container->get(UserPasswordHasherInterface::class); diff --git a/tests/SmokeTest.php b/tests/Integration/SmokeTest.php similarity index 91% rename from tests/SmokeTest.php rename to tests/Integration/SmokeTest.php index 880a636..c3739c6 100644 --- a/tests/SmokeTest.php +++ b/tests/Integration/SmokeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests; +namespace App\Tests\Integration; use App\Kernel; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; diff --git a/tests/Support/ResetsDatabaseSchemaTrait.php b/tests/Support/ResetsDatabaseSchemaTrait.php deleted file mode 100644 index 4e9619a..0000000 --- a/tests/Support/ResetsDatabaseSchemaTrait.php +++ /dev/null @@ -1,34 +0,0 @@ -getMetadataFactory()->getAllMetadata(); - $schemaTool->dropDatabase(); - $schemaTool->createSchema($metadata); - } -} diff --git a/tests/KernelTest.php b/tests/Unit/KernelTest.php similarity index 96% rename from tests/KernelTest.php rename to tests/Unit/KernelTest.php index c7e1a2e..a9c98be 100644 --- a/tests/KernelTest.php +++ b/tests/Unit/KernelTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests; +namespace App\Tests\Unit; use App\Kernel; use PHPUnit\Framework\TestCase; diff --git a/tests/Twig/DevTemplateMarkerExtensionTest.php b/tests/Unit/Twig/DevTemplateMarkerExtensionTest.php similarity index 96% rename from tests/Twig/DevTemplateMarkerExtensionTest.php rename to tests/Unit/Twig/DevTemplateMarkerExtensionTest.php index 8005785..11c521c 100644 --- a/tests/Twig/DevTemplateMarkerExtensionTest.php +++ b/tests/Unit/Twig/DevTemplateMarkerExtensionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\Twig; +namespace App\Tests\Unit\Twig; use App\Twig\DevTemplateMarkerExtension; use App\Twig\DevTemplateMarkerNodeVisitor; diff --git a/tests/Twig/DevTemplateMarkerNodeVisitorTest.php b/tests/Unit/Twig/DevTemplateMarkerNodeVisitorTest.php similarity index 98% rename from tests/Twig/DevTemplateMarkerNodeVisitorTest.php rename to tests/Unit/Twig/DevTemplateMarkerNodeVisitorTest.php index 3ffa965..b8e6048 100644 --- a/tests/Twig/DevTemplateMarkerNodeVisitorTest.php +++ b/tests/Unit/Twig/DevTemplateMarkerNodeVisitorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\Twig; +namespace App\Tests\Unit\Twig; use App\Twig\DevTemplateMarkerNodeVisitor; use PHPUnit\Framework\TestCase; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 47a5855..2c726a9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,5 +1,8 @@ boot(); + $em = $kernel->getContainer()->get('doctrine')->getManager(); + \assert($em instanceof EntityManagerInterface); + $schemaTool = new SchemaTool($em); + $schemaTool->dropDatabase(); + $schemaTool->createSchema($em->getMetadataFactory()->getAllMetadata()); + $kernel->shutdown(); +} From 335e417be9dc5faf2e2ed424bb0b472329057a11 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Mon, 15 Jun 2026 14:25:55 +0200 Subject: [PATCH 13/14] feat(twig): extract Form/Label, Form/Input, Form/Button components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #59 review item 7: yepzdk asked that the button, input, and label in templates/security/login.html.twig become reusable Twig components so styling and behaviour live in one place. Done via the existing anonymous-component pattern this project already uses for Eyebrow, Box, Nav/Link, etc. - templates/components/Form/Label.html.twig wraps the label text + span + slotted content. Takes `for` and `text` props. - templates/components/Form/Input.html.twig accepts id, name, type (default text), value, autocomplete, required, autofocus. The Tailwind class set is encapsulated in the component. - templates/components/Form/Button.html.twig accepts type (default submit), variant (default primary), size (default md). The variant and size maps currently hold one entry each — the API is set up so secondary/danger variants and sm/lg sizes can be added in one place. - templates/security/login.html.twig now uses these three components instead of inlining the markup. Verified: task test (32/32, including SecurityControllerTest which asserts input[name="_username"], input[name="_password"], and a submit button via the DOM crawler) and task test-coverage (100%). task coding-standards-twig-check passes. --- CHANGELOG.md | 4 ++ templates/components/Form/Button.html.twig | 17 ++++++++ templates/components/Form/Input.html.twig | 17 ++++++++ templates/components/Form/Label.html.twig | 5 +++ templates/security/login.html.twig | 45 +++++++++++----------- 5 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 templates/components/Form/Button.html.twig create mode 100644 templates/components/Form/Input.html.twig create mode 100644 templates/components/Form/Label.html.twig diff --git a/CHANGELOG.md b/CHANGELOG.md index ef4831c..2cc97da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `dama/doctrine-test-bundle`. Schema is built once from ORM metadata in `tests/bootstrap.php`. `task test-unit` and `task test-integration` expose the suites individually. +- Reusable Twig form components under `templates/components/Form/`: + `Form/Label`, `Form/Input`, and `Form/Button` (with `variant` and + `size` props for future styling variants). The `/login` template + consumes them instead of inlining the input/label/button markup. - Site chrome (header with brand + nav, footer) in `templates/base.html.twig`, with the Fraunces/Geist font stack preloaded from Google Fonts. diff --git a/templates/components/Form/Button.html.twig b/templates/components/Form/Button.html.twig new file mode 100644 index 0000000..1d26f65 --- /dev/null +++ b/templates/components/Form/Button.html.twig @@ -0,0 +1,17 @@ +{% props + type = 'submit', + variant = 'primary', + size = 'md', +%} +{# `variant` and `size` map to Tailwind utility groups so future variants #} +{# (secondary, danger, etc.) and sizes (sm, lg) can be added in one place. #} +{% set variants = { + primary: 'bg-primary text-primary-ink hover:bg-primary-hover', +} %} +{% set sizes = { + md: 'px-4 py-2', +} %} + diff --git a/templates/components/Form/Input.html.twig b/templates/components/Form/Input.html.twig new file mode 100644 index 0000000..cc56f95 --- /dev/null +++ b/templates/components/Form/Input.html.twig @@ -0,0 +1,17 @@ +{% props + id, + name, + type = 'text', + value = '', + autocomplete = null, + required = false, + autofocus = false, +%} + diff --git a/templates/components/Form/Label.html.twig b/templates/components/Form/Label.html.twig new file mode 100644 index 0000000..b49663c --- /dev/null +++ b/templates/components/Form/Label.html.twig @@ -0,0 +1,5 @@ +{% props for, text %} + diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index ee1ed48..e6afd73 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -16,34 +16,33 @@ {% endif %}
- + + + - + + + - +
{% endblock %} From 4d53cef28755a50be66767539214031b796f420e Mon Sep 17 00:00:00 2001 From: martinyde Date: Mon, 15 Jun 2026 14:27:08 +0200 Subject: [PATCH 14/14] Update comment --- tests/bootstrap.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 2c726a9..dc2bbf6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,8 +15,7 @@ umask(0000); } -// `task test-unit` sets TESTS_SKIP_SCHEMA=1 so unit-only runs do not -// require a live database. The integration suite needs the schema in +// The integration suite needs the schema in // place before DAMA starts wrapping tests in transactions, so build it // here from the current ORM metadata. if ('1' !== ($_SERVER['TESTS_SKIP_SCHEMA'] ?? '')) {