diff --git a/.docker/data/.gitignore b/.docker/data/.gitignore new file mode 100644 index 0000000..4ce1020 --- /dev/null +++ b/.docker/data/.gitignore @@ -0,0 +1,5 @@ +# Ignore everything in this directory +* +# Except +!.gitignore +!README.md diff --git a/.docker/data/README.md b/.docker/data/README.md new file mode 100644 index 0000000..f587ac7 --- /dev/null +++ b/.docker/data/README.md @@ -0,0 +1,5 @@ +# .docker/data + +Please map persistent volumes to this directory on the servers. + +If a container needs to persist data between restarts you can map the relevant files in the container to ``docker/data/`. diff --git a/.docker/nginx.conf b/.docker/nginx.conf new file mode 100644 index 0000000..ec278a5 --- /dev/null +++ b/.docker/nginx.conf @@ -0,0 +1,34 @@ +worker_processes auto; + +error_log /dev/stderr notice; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + proxy_temp_path /tmp/proxy_temp; + client_body_temp_path /tmp/client_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Note: set_real_ip_from is set in the server block + + log_format main '$http_x_real_ip - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /dev/stdout main; + + sendfile on; + keepalive_timeout 65; + + gzip on; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/.docker/templates/default.conf.template b/.docker/templates/default.conf.template new file mode 100644 index 0000000..36ddf04 --- /dev/null +++ b/.docker/templates/default.conf.template @@ -0,0 +1,56 @@ +server { + listen ${NGINX_PORT}; + server_name localhost; + + root ${NGINX_WEB_ROOT}; + + client_max_body_size ${NGINX_MAX_BODY_SIZE}; + + set_real_ip_from 172.16.0.0/16; + set_real_ip_from 192.168.39.0/24; + real_ip_recursive on; + real_ip_header X-Forwarded-For; + + location = /cron-metrics { + # Proxy to supercronic metrics + proxy_pass http://${NGINX_CRON_METRICS}/metrics; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + # try to serve file directly, fallback to index.php + try_files $uri /index.php$is_args$args; + } + + # Protect files and directories from prying eyes. + location ~* \.(engine|inc|install|make|module|profile|po|sh|.*sql|.tar|.gz|.bz2|theme|twig|tpl(\.php)?|xtmpl|yml)(~|\.sw[op]|\.bak|\.orig|\.save)?$|^(\.(?!well-known).*|Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config)$|^#.*#$|\.php(~|\.sw[op]|\.bak|\.orig|\.save)$ { + deny all; + return 404; + } + + location ~ ^/index\.php(/|$) { + fastcgi_buffers 16 32k; + fastcgi_buffer_size 64k; + fastcgi_busy_buffers_size 64k; + + fastcgi_pass ${NGINX_FPM_SERVICE}; + fastcgi_split_path_info ^(.+\.php)(/.*)$; + include fastcgi_params; + + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + + internal; + } + + location ~ \.php$ { + return 404; + } + + # Send log message to files symlinked to stdout/stderr. + error_log /dev/stderr; + access_log /dev/stdout main; +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6699076 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{compose.yaml,compose.*.yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env b/.env new file mode 100644 index 0000000..d142ac5 --- /dev/null +++ b/.env @@ -0,0 +1,51 @@ +COMPOSE_PROJECT_NAME=itk-project-database +COMPOSE_DOMAIN=itk-project-database.local.itkdev.dk +ITKDEV_TEMPLATE=symfony-8 + +# In all environments, the following files are loaded if they exist, +# the latter taking precedence over the former: +# +# * .env contains default values for the environment variables needed by the app +# * .env.local uncommitted file with local overrides +# * .env.$APP_ENV committed environment-specific defaults +# * .env.$APP_ENV.local uncommitted environment-specific overrides +# +# Real environment variables win over .env files. +# +# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. +# https://symfony.com/doc/current/configuration/secrets.html +# +# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). +# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration + +###> symfony/framework-bundle ### +APP_ENV=dev +APP_SECRET=dev-secret-not-for-production-override-in-prod +APP_SHARE_DIR=var/share +# Trust the local Docker reverse proxy (Traefik -> nginx) so X-Forwarded-Proto is +# honoured. nginx's real_ip sets REMOTE_ADDR to the real client, so trust REMOTE_ADDR. +TRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR +###< symfony/framework-bundle ### + +###> symfony/routing ### +# Configure how to generate URLs in non-HTTP contexts, such as CLI commands. +# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands +DEFAULT_URI=http://localhost +###< symfony/routing ### + +###> doctrine/doctrine-bundle ### +# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url +# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml +# +# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" +# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" +# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" +DATABASE_URL="mysql://db:db@mariadb:3306/db?serverVersion=10.11.2-MariaDB&charset=utf8mb4" +###< doctrine/doctrine-bundle ### + +###> app ### +# Maximum upload sizes for initiative media. Accepts a number of bytes or a +# shorthand suffix (k, M, G), e.g. "8M" — see the Symfony File/Image constraint. +INITIATIVE_IMAGE_MAX_SIZE=8M +INITIATIVE_ATTACHMENT_MAX_SIZE=16M +###< app ### diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..aef8727 --- /dev/null +++ b/.env.test @@ -0,0 +1,8 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +APP_SECRET='$ecretf0rt3st' + +# Tests connect as root so the suite can create/drop the ephemeral test database +# (the unprivileged app user only has access to the main database). Credentials +# match the dev MariaDB container; serverVersion is overridable for the CI matrix. +DATABASE_URL="mysql://root:password@mariadb:3306/db?serverVersion=${MARIADB_VERSION:-10.11.2-MariaDB}&charset=utf8mb4" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..333ba2f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +#### Link to ticket + +Please add a link to the ticket being addressed by this change. + +#### Description + +Please include a short description of the suggested change and the reasoning behind the approach you have chosen. + +#### Screenshot of the result + +If your change affects the user interface you should include a screenshot of the result with the pull request. + +#### Checklist + +- [ ] My code is covered by test cases. +- [ ] My code passes our test (all our tests). +- [ ] My code passes our static analysis suite. +- [ ] My code passes our continuous integration process. diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml new file mode 100644 index 0000000..a646b05 --- /dev/null +++ b/.github/workflows/changelog.yaml @@ -0,0 +1,27 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/changelog.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Changelog +### +### Checks that changelog has been updated + +name: Changelog + +on: + pull_request: + +jobs: + changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Git fetch + run: git fetch + + - name: Check that changelog has been updated. + run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0 diff --git a/.github/workflows/composer.yaml b/.github/workflows/composer.yaml new file mode 100644 index 0000000..26a728a --- /dev/null +++ b/.github/workflows/composer.yaml @@ -0,0 +1,79 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/composer.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Composer +### +### Validates composer.json and checks that it's normalized. +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. +### 2. [ergebnis/composer-normalize](https://github.com/ergebnis/composer-normalize) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev ergebnis/composer-normalize +### ``` +### +### Normalize `composer.json` by running +### +### ``` shell +### docker compose run --rm phpfpm composer normalize +### ``` + +name: Composer + +env: + COMPOSE_USER: runner + +on: + pull_request: + paths: &paths + - "composer.json" + - "composer.lock" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + composer-validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer validate --strict + + composer-normalized: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm composer normalize --dry-run + + composer-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer audit --locked diff --git a/.github/workflows/javascript.yaml b/.github/workflows/javascript.yaml new file mode 100644 index 0000000..37f439a --- /dev/null +++ b/.github/workflows/javascript.yaml @@ -0,0 +1,39 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/symfony/javascript.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Symfony JavaScript (and TypeScript) +### +### Validates JavaScript files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. + +name: JavaScript + +on: + pull_request: + paths: &paths + - "assets/**/*.js" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + javascript-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm prettier 'assets/**/*.js' --check diff --git a/.github/workflows/markdown.yaml b/.github/workflows/markdown.yaml new file mode 100644 index 0000000..a2e1988 --- /dev/null +++ b/.github/workflows/markdown.yaml @@ -0,0 +1,45 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/markdown.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Markdown +### +### Lints Markdown files (`**/*.md`) in the project. +### +### [markdownlint-cli configuration +### files](https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration), +### `.markdownlint.jsonc` and `.markdownlintignore`, control what is actually +### linted and how. +### +### #### Assumptions +### +### 1. A docker compose service named `markdownlint` for running `markdownlint` +### (from +### [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli)) +### exists. + +name: Markdown + +on: + pull_request: + paths: &paths + - "**/*.md" + push: + branches: + - main + - develop + paths: *paths + +jobs: + markdown-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm markdownlint markdownlint '**/*.md' diff --git a/.github/workflows/php.yaml b/.github/workflows/php.yaml new file mode 100644 index 0000000..2b958dc --- /dev/null +++ b/.github/workflows/php.yaml @@ -0,0 +1,66 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/symfony/php.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Symfony PHP +### +### Checks that PHP code adheres to the [Symfony coding +### standards](https://symfony.com/doc/current/contributing/code/standards.html). +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. 2. +### [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev friendsofphp/php-cs-fixer +### ``` +### +### Clean up and check code by running +### +### ``` shell +### docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix +### docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix --dry-run --diff +### ``` +### +### > [!NOTE] The template adds `.php-cs-fixer.dist.php` as [a configuration +### > file for PHP CS +### > Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/config.rst) +### > and this makes it possible to override the actual configuration used in a +### > project by adding a more important configuration file, `.php-cs-fixer.php`. + +name: Symfony PHP + +env: + COMPOSE_USER: runner + +on: + pull_request: + paths: &paths + - "**/*.php" + - "composer.json" + - "composer.lock" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + coding-standards: + name: PHP - Check Coding Standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer install + # https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/doc/usage.rst#the-check-command + docker compose run --rm phpfpm vendor/bin/php-cs-fixer fix --dry-run --diff diff --git a/.github/workflows/styles.yaml b/.github/workflows/styles.yaml new file mode 100644 index 0000000..97cd8a3 --- /dev/null +++ b/.github/workflows/styles.yaml @@ -0,0 +1,40 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/symfony/styles.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Symfony Styles (CSS and SCSS) +### +### Validates styles files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. + +name: Styles + +on: + pull_request: + paths: &paths + - "assets/**/*.css" + - "assets/**/*.scss" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + styles-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm prettier 'assets/**/*.{css,scss}' --check diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..f19e1be --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,51 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + - develop + +env: + COMPOSE_USER: runner + # Consumed by docker-compose.yml (${PHP_XDEBUG_MODE:-off}); loads Xdebug in + # coverage mode so PHPUnit can produce the Clover report. + PHP_XDEBUG_MODE: coverage + +jobs: + phpunit: + runs-on: ubuntu-latest + name: PHPUnit + coverage gate + env: + # Test against the production MariaDB version (also pinned in .env). + # Picked up by docker-compose.yml's `image: ${MARIADB_IMAGE:-…}`. + MARIADB_IMAGE: mariadb:10.11 + # Forwarded into phpfpm via `-e MARIADB_VERSION`; .env.test interpolates + # it into Doctrine's serverVersion URL parameter. + MARIADB_VERSION: 10.11.13-MariaDB + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Cache vendor + uses: actions/cache@v4 + with: + path: vendor + key: vendor-php8.4-${{ hashFiles('composer.lock') }} + restore-keys: vendor-php8.4- + + - name: Create docker network + run: docker network create frontend + + - name: Pull container images + run: docker compose pull --quiet phpfpm mariadb + + - name: Install dependencies + run: docker compose run --rm phpfpm composer install + + - name: Prepare the test database + run: docker compose run --rm -e MARIADB_VERSION phpfpm composer run test-setup + + - name: Run tests and enforce 100% coverage + run: docker compose run --rm -e MARIADB_VERSION phpfpm composer run test-coverage diff --git a/.github/workflows/twig.yaml b/.github/workflows/twig.yaml new file mode 100644 index 0000000..1d7cd59 --- /dev/null +++ b/.github/workflows/twig.yaml @@ -0,0 +1,55 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/twig.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### Twig +### +### Validates Twig files +### +### #### Assumptions +### +### 1. A docker compose service named `phpfpm` can be run and `composer` can be +### run inside the `phpfpm` service. +### 2. [vincentlanglet/twig-cs-fixer](https://github.com/VincentLanglet/Twig-CS-Fixer) +### is a dev requirement in `composer.json`: +### +### ``` shell +### docker compose run --rm phpfpm composer require --dev vincentlanglet/twig-cs-fixer +### ``` +### +### 3. A [Configuration +### file](https://github.com/VincentLanglet/Twig-CS-Fixer/blob/main/docs/configuration.md#configuration-file) +### in the root of the project defines which files to check and rules to use. + +name: Twig + +env: + COMPOSE_USER: runner + +on: + pull_request: + paths: &paths + - "**/*.twig" + - "composer.json" + - "composer.lock" + - "docker-compose.yml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + twig-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm phpfpm composer install + docker compose run --rm phpfpm vendor/bin/twig-cs-fixer lint diff --git a/.github/workflows/yaml.yaml b/.github/workflows/yaml.yaml new file mode 100644 index 0000000..051ffd9 --- /dev/null +++ b/.github/workflows/yaml.yaml @@ -0,0 +1,45 @@ +# Do not edit this file! Make a pull request on changing +# github/workflows/yaml.yaml in +# https://github.com/itk-dev/devops_itkdev-docker if need be. + +### ### YAML +### +### Validates YAML files. +### +### #### Assumptions +### +### 1. A docker compose service named `prettier` for running +### [Prettier](https://prettier.io/) exists. +### +### #### Symfony YAML +### +### Symfony's YAML config files use 4 spaces for indentation and single quotes. +### Therefore we use a [Prettier configuration +### file](https://prettier.io/docs/configuration), `.prettierrc.yaml`, to make +### Prettier format YAML files in the `config/` folder like Symfony expects. + +name: YAML + +on: + pull_request: + paths: &paths + - "**/*.yml" + - "**/*.yaml" + push: + branches: + - main + - develop + paths: *paths + +jobs: + yaml-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - run: | + docker compose run --rm prettier '**/*.{yml,yaml}' --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71af670 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ + +###> symfony/framework-bundle ### +/.env.local +/.env.dev +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> symfony/asset-mapper ### +/public/assets/ +/assets/vendor/ +###< symfony/asset-mapper ### + +###> friendsofphp/php-cs-fixer ### +/.php-cs-fixer.php +/.php-cs-fixer.cache +###< friendsofphp/php-cs-fixer ### + +###> phpstan/phpstan ### +phpstan.neon +###< phpstan/phpstan ### + +###> vincentlanglet/twig-cs-fixer ### +/.twig-cs-fixer.cache +###< vincentlanglet/twig-cs-fixer ### + +###> phpunit/phpunit ### +/phpunit.xml +/.phpunit.cache/ +###< phpunit/phpunit ### + +/coverage/ +/.idea/ + +# Auto-generated config reference (IDE type hints); regenerated on cache warmup. +/config/reference.php diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..0253096 --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,22 @@ +// This file is copied from config/markdown/.markdownlint.jsonc in https://github.com/itk-dev/devops_itkdev-docker. +// Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +// markdownlint-cli configuration file (cf. https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#configuration) +{ + "default": true, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md + "line-length": { + "line_length": 120, + "code_blocks": false, + "tables": false + }, + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md + "no-duplicate-heading": { + "siblings_only": true + }, + // https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-collapsed-sections#creating-a-collapsed-section + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md033.md + "no-inline-html": { + "allowed_elements": ["details", "summary"] + } +} diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..d143ace --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,12 @@ +# This file is copied from config/markdown/.markdownlintignore in https://github.com/itk-dev/devops_itkdev-docker. +# Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +# https://github.com/igorshubovych/markdownlint-cli?tab=readme-ov-file#ignoring-files +vendor/ +node_modules/ +LICENSE.md +# Drupal +web/*.md +web/core/ +web/libraries/ +web/*/contrib/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..c23b927 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,20 @@ +in(__DIR__); +// … that are not ignored by VCS +$finder->ignoreVCSIgnored(true); + +$config = new PhpCsFixer\Config(); +$config->setFinder($finder); + +$config->setRules([ + '@Symfony' => true, +]); + +return $config; diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..a99aea9 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,18 @@ +# This file is copied from config/symfony/yaml/.prettierrc.yaml in https://github.com/itk-dev/devops_itkdev-docker. +# Feel free to edit the file, but consider making a pull request if you find a general issue with the file. + +# https://prettier.io/docs/configuration +overrides: + # https://taskfile.dev/docs/styleguide + - files: + - "Taskfile.{yml,yaml}" + options: + tabWidth: 2 + singleQuote: true + + # Symfony config + - files: + - "config/**/*.{yml,yaml}" + options: + tabWidth: 4 + singleQuote: true diff --git a/.twig-cs-fixer.dist.php b/.twig-cs-fixer.dist.php new file mode 100644 index 0000000..8242555 --- /dev/null +++ b/.twig-cs-fixer.dist.php @@ -0,0 +1,16 @@ +in(__DIR__); +// … that are not ignored by VCS +$finder->ignoreVCSIgnored(true); + +$config = new TwigCsFixer\Config\Config(); +$config->setFinder($finder); + +return $config; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..750c02a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +* [PR-1](https://github.com/itk-dev/itk-project-database/pull/1) + Initial Symfony 8 rebuild of the project database. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d70e345 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# Project database + +A Symfony application for registering and browsing municipal **initiatives** and +their **contacts**. It is a rebuild of the previous Drupal-based project +database, focused on a friendlier interface for creating and getting an overview +of initiatives. + +The project follows the itk-dev +[`symfony` Docker template](https://github.com/itk-dev/devops_itkdev-docker) and +runs on PHP 8.4 / Symfony 8. + +## Features + +- Dashboard with key figures and a status overview of all initiatives. +- List of initiatives with full-text search, faceted filters, column sorting, + pagination and CSV export. +- Create and edit initiatives, including inline creation of contacts, + free-tagging of tags, stakeholders and strategies, and image/file uploads. +- Contact management. +- Private file/image uploads, served only to signed-in users. +- Local username/password login with user administration for administrators. +- Bilingual interface (Danish and English). + +## Requirements + +- [Docker](https://www.docker.com/) and the itk-dev + [`itkdev-docker-compose`](https://github.com/itk-dev/devops_itkdev-docker) setup. +- [Task](https://taskfile.dev/) (optional, but the commands below use it). + +## Installation + +Start the containers and install everything (dependencies, database schema and +development fixtures): + +```sh +task install +``` + +Without Task: + +```sh +docker compose up --detach +docker compose exec phpfpm composer install +docker compose exec phpfpm bin/console doctrine:migrations:migrate --no-interaction +docker compose exec phpfpm bin/console doctrine:fixtures:load --no-interaction +``` + +The site is served on the domain configured in `.env` +(`COMPOSE_DOMAIN`, e.g. `https://itk-project-database.local.itkdev.dk`). + +### Signing in + +The development fixtures create two users (password `password` for both): + +- `admin@example.com` — administrator +- `editor@example.com` — editor + +Create an administrator manually with: + +```sh +task create-admin -- you@example.com "Your Name" +``` + +## Access control + +The application uses a single, flat trust model: authentication is required for +everything (the firewall protects `^/`), and **every authenticated user is fully +trusted**. Any signed-in user (`ROLE_USER`) can create, view, edit and delete any +initiative or contact, and can download any uploaded image or attachment by id. + +The only elevated capability is **user administration** (`/admin/**`), which +requires `ROLE_ADMIN`. + +This is intentional: the project database is an internal tool for a small, +trusted group of municipal editors, so per-record ownership or per-action +authorization would add complexity without a real security benefit. Uploaded +files are stored outside the web root and served only through the authenticated +`MediaController`, so they are never anonymously reachable — but they are not +restricted between authenticated users. + +If a future requirement calls for restricting who may edit/delete a given record +(or read a given file), introduce a Symfony [Voter](https://symfony.com/doc/current/security/voters.html) +rather than loosening or working around the flat model. + +## Development + +```sh +task # list all tasks +task console -- # run a Symfony console command +task coding-standards:fix # apply coding standards +task static-analysis # run PHPStan +task test # run the test suite +task ci # run everything CI runs +``` + +### Note on controlled vocabularies + +The controlled vocabularies (status, category, type, organisational anchoring, +endorsement author and funding) are modelled as PHP enums in `src/Enum/` with +placeholder values. Adjust the enum cases and their translations +(`translations/messages.*.yaml`) to match the real domain values. diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..4dd5255 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,91 @@ +version: '3' + +dotenv: ['.env.local', '.env'] + +vars: + DOCKER_COMPOSE: '{{.TASK_DOCKER_COMPOSE | default "docker compose"}}' + COMPOSER: '{{.DOCKER_COMPOSE}} exec phpfpm composer' + CONSOLE: '{{.DOCKER_COMPOSE}} exec phpfpm bin/console' + PHPUNIT: '{{.DOCKER_COMPOSE}} exec phpfpm bin/phpunit' + PHP_CS_FIXER: '{{.DOCKER_COMPOSE}} exec phpfpm vendor/bin/php-cs-fixer' + PHPSTAN: '{{.DOCKER_COMPOSE}} exec phpfpm vendor/bin/phpstan' + TWIG_CS_FIXER: '{{.DOCKER_COMPOSE}} exec phpfpm vendor/bin/twig-cs-fixer' + PRETTIER: '{{.DOCKER_COMPOSE}} run --rm prettier' + MARKDOWNLINT: '{{.DOCKER_COMPOSE}} run --rm markdownlint markdownlint' + +tasks: + compose: + desc: 'Install Composer dependencies' + cmds: + - '{{.COMPOSER}} {{.CLI_ARGS}}' + install: + desc: 'First-time setup: start, install deps, migrate, load fixtures' + cmds: + - '{{.DOCKER_COMPOSE}} up -d' + - '{{.COMPOSER}} install' + - '{{.CONSOLE}} doctrine:database:create --if-not-exists' + - '{{.CONSOLE}} doctrine:migrations:migrate --no-interaction' + - '{{.CONSOLE}} doctrine:fixtures:load --no-interaction' + - '{{.CONSOLE}} asset-map:compile' + + console: + desc: 'Run a Symfony console command (usage: task console -- )' + cmds: + - '{{.CONSOLE}} {{.CLI_ARGS}}' + + coding-standards:check: + desc: 'Check coding standards' + cmds: + - '{{.PHP_CS_FIXER}} fix --dry-run --diff' + - '{{.TWIG_CS_FIXER}} lint' + - "{{.PRETTIER}} 'assets/**/*.js' --check" + - "{{.PRETTIER}} 'assets/**/*.{css,scss}' --check" + - "{{.PRETTIER}} '**/*.{yml,yaml}' --check" + - "{{.MARKDOWNLINT}} '**/*.md'" + + coding-standards:apply: + desc: 'Apply coding standards' + cmds: + - '{{.PHP_CS_FIXER}} fix' + - '{{.TWIG_CS_FIXER}} lint --fix' + - "{{.PRETTIER}} 'assets/**/*.js' --write" + - "{{.PRETTIER}} 'assets/**/*.{css,scss}' --write" + - "{{.PRETTIER}} '**/*.{yml,yaml}' --write" + + static-analysis: + desc: 'Run PHPStan static analysis' + cmds: + - '{{.PHPSTAN}} analyse --memory-limit=1G' + + test:setup: + desc: 'Create the test database, run migrations and load fixtures' + cmds: + - '{{.DOCKER_COMPOSE}} exec phpfpm composer run test-setup' + + test: + desc: 'Run the PHPUnit test suite' + cmds: + - '{{.PHPUNIT}} {{.CLI_ARGS}}' + + test:coverage: + desc: 'Run tests with coverage and enforce the 100% gate' + cmds: + - '{{.DOCKER_COMPOSE}} exec -e XDEBUG_MODE=coverage phpfpm composer run test-coverage' + + create-admin: + desc: 'Create (or update) an administrator (usage: task create-admin -- you@example.com "Your Name")' + cmds: + - '{{.CONSOLE}} app:create-admin {{.CLI_ARGS}}' + + ci: + desc: 'Run everything CI runs' + cmds: + - task: coding-standards:check + - task: static-analysis + - task: test:setup + - task: test:coverage + + default: + cmds: + - task --list + silent: true diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..27ff6a1 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,80 @@ +import "./styles/app.css"; +import TomSelect from "tom-select"; +import "tom-select/dist/css/tom-select.default.min.css"; + +function initUserMenu() { + const toggle = document.getElementById("userMenuToggle"); + const menu = document.getElementById("userMenu"); + if (!toggle || !menu) { + return; + } + + toggle.addEventListener("click", (event) => { + event.stopPropagation(); + menu.classList.toggle("is-open"); + }); + + document.addEventListener("click", (event) => { + if (!menu.contains(event.target) && event.target !== toggle) { + menu.classList.remove("is-open"); + } + }); +} + +function initCollections() { + document.querySelectorAll("[data-collection]").forEach((collection) => { + const list = collection.querySelector("[data-collection-list]"); + const addButton = collection.querySelector("[data-collection-add]"); + if (!list || !addButton) { + return; + } + + let index = list.querySelectorAll("[data-collection-item]").length; + + const addRemoveButton = (item) => { + if (item.querySelector("[data-collection-remove]")) { + return; + } + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "btn btn--danger btn--sm"; + remove.dataset.collectionRemove = ""; + remove.textContent = collection.dataset.removeLabel || "Remove"; + remove.addEventListener("click", () => item.remove()); + item.appendChild(remove); + }; + + list.querySelectorAll("[data-collection-item]").forEach( + addRemoveButton, + ); + + addButton.addEventListener("click", () => { + const prototype = collection.dataset.prototype; + const html = prototype.replace(/__name__/g, String(index)); + index += 1; + + const wrapper = document.createElement("div"); + wrapper.className = "collection__item"; + wrapper.dataset.collectionItem = ""; + wrapper.innerHTML = html; + addRemoveButton(wrapper); + list.appendChild(wrapper); + }); + }); +} + +function initContactSelect() { + document.querySelectorAll("[data-contact-select]").forEach((select) => { + new TomSelect(select, { + plugins: ["remove_button"], + hideSelected: true, + maxOptions: null, + }); + }); +} + +document.addEventListener("DOMContentLoaded", () => { + initUserMenu(); + initCollections(); + initContactSelect(); +}); diff --git a/assets/styles/app.css b/assets/styles/app.css new file mode 100644 index 0000000..49007e4 --- /dev/null +++ b/assets/styles/app.css @@ -0,0 +1,1219 @@ +:root { + --itk-red: #e44930; + --itk-lime: #89bd23; + --itk-mint: #73bc99; + --itk-blue: #007ba6; + --itk-cyan: #00a5cd; + --itk-aqua: #00b5c9; + --itk-green: #008d3d; + + --itk-primary: var(--itk-blue); + --itk-primary-hover: #006385; + --itk-accent: var(--itk-red); + + --itk-ink: #111318; + --itk-ink-soft: #2a2f38; + --itk-slate-700: #3f4654; + --itk-slate-500: #6b7484; + --itk-slate-300: #c7ccd4; + --itk-slate-200: #dfe3ea; + --itk-slate-100: #eef1f5; + --itk-slate-50: #f7f8fa; + --itk-paper: #ffffff; + + --itk-bg: var(--itk-slate-50); + --itk-surface: var(--itk-paper); + --itk-bg-inverse: var(--itk-ink); + + --itk-font-sans: "Inter Tight", "Helvetica Neue", Arial, sans-serif; + --itk-font-mono: + "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + + --itk-text-xs: 12px; + --itk-text-sm: 14px; + --itk-text-base: 16px; + --itk-text-md: 18px; + --itk-text-lg: 22px; + --itk-text-xl: 28px; + --itk-text-2xl: 36px; + + --itk-space-1: 4px; + --itk-space-2: 8px; + --itk-space-3: 12px; + --itk-space-4: 16px; + --itk-space-5: 24px; + --itk-space-6: 32px; + --itk-space-7: 48px; + --itk-space-8: 64px; + + --itk-radius-1: 2px; + --itk-radius-2: 6px; + --itk-radius-3: 10px; + --itk-radius-4: 16px; + --itk-radius-pill: 999px; + + --itk-shadow-1: 0 1px 2px rgba(17, 19, 24, 0.06); + --itk-shadow-2: + 0 4px 10px -2px rgba(17, 19, 24, 0.08), + 0 2px 4px -1px rgba(17, 19, 24, 0.04); + --itk-shadow-3: + 0 16px 32px -12px rgba(17, 19, 24, 0.18), + 0 4px 8px -2px rgba(17, 19, 24, 0.06); + + --itk-focus: 0 0 0 3px rgba(0, 123, 166, 0.35); + + --itk-container: 1200px; + + --itk-success: var(--itk-green); + --itk-warning: #b35a00; + --itk-danger: var(--itk-red); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + height: 100%; +} + +body { + font-family: var(--itk-font-sans); + background-color: var(--itk-bg); + color: var(--itk-ink); + line-height: 1.55; + min-height: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: "cv11", "ss01"; +} + +a { + color: var(--itk-primary); + text-decoration: none; + text-underline-offset: 3px; +} + +a:hover { + color: var(--itk-primary-hover); + text-decoration: underline; +} + +h1, +h2, +h3 { + line-height: 1.2; + letter-spacing: -0.01em; + color: var(--itk-ink); +} + +:focus-visible { + outline: none; + box-shadow: var(--itk-focus); + border-radius: var(--itk-radius-1); +} + +.top-nav { + background-color: var(--itk-paper); + border-bottom: 1px solid var(--itk-slate-200); + padding: 0 var(--itk-space-5); + height: 64px; + display: flex; + align-items: center; + gap: var(--itk-space-5); + position: sticky; + top: 0; + z-index: 100; +} + +.top-nav__brand { + font-size: var(--itk-text-base); + font-weight: 700; + color: var(--itk-ink); + display: inline-flex; + align-items: center; + gap: var(--itk-space-2); + letter-spacing: -0.01em; +} + +.top-nav__brand:hover { + color: var(--itk-ink); + text-decoration: none; +} + +.top-nav__brand-mark { + width: 28px; + height: 28px; + border-radius: var(--itk-radius-2); + background: linear-gradient(135deg, var(--itk-blue), var(--itk-cyan)); + display: inline-block; +} + +.top-nav__menu { + display: flex; + align-items: center; + gap: var(--itk-space-1); + margin-left: var(--itk-space-4); +} + +.top-nav__link { + font-size: var(--itk-text-sm); + font-weight: 500; + color: var(--itk-slate-700); + padding: var(--itk-space-2) var(--itk-space-3); + border-radius: var(--itk-radius-2); + transition: + background-color 0.15s, + color 0.15s; +} + +.top-nav__link:hover { + color: var(--itk-ink); + background-color: var(--itk-slate-100); + text-decoration: none; +} + +.top-nav__link.is-active { + color: var(--itk-blue); + background-color: color-mix(in srgb, var(--itk-blue) 10%, transparent); +} + +.top-nav__right { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--itk-space-3); +} + +.top-nav__menu-anchor { + position: relative; +} + +.user-menu__toggle { + display: inline-flex; + align-items: center; + gap: var(--itk-space-2); + background: none; + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-pill); + padding: var(--itk-space-1) var(--itk-space-3); + font: inherit; + font-size: var(--itk-text-sm); + font-weight: 500; + color: var(--itk-ink); + cursor: pointer; +} + +.user-menu__toggle:hover { + background-color: var(--itk-slate-100); +} + +.user-menu { + display: none; + position: absolute; + top: calc(100% + var(--itk-space-2)); + right: 0; + background: var(--itk-paper); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-3); + min-width: 240px; + box-shadow: var(--itk-shadow-3); + z-index: 200; + overflow: hidden; + padding: var(--itk-space-2); +} + +.user-menu.is-open { + display: block; +} + +.user-menu__header { + padding: var(--itk-space-3); + border-bottom: 1px solid var(--itk-slate-100); + margin-bottom: var(--itk-space-2); +} + +.user-menu__eyebrow { + font-size: var(--itk-text-xs); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--itk-slate-500); +} + +.user-menu__name { + font-weight: 600; +} + +.user-menu__email { + font-size: var(--itk-text-sm); + color: var(--itk-slate-500); +} + +.user-menu__link, +.user-menu__logout { + display: block; + padding: var(--itk-space-2) var(--itk-space-3); + border-radius: var(--itk-radius-2); + font-size: var(--itk-text-sm); + color: var(--itk-slate-700); +} + +.user-menu__link:hover, +.user-menu__logout:hover { + background-color: var(--itk-slate-100); + text-decoration: none; +} + +.user-menu__logout { + color: var(--itk-danger); +} + +.user-menu__section { + padding: var(--itk-space-2) var(--itk-space-3); +} + +.lang-toggle { + display: inline-flex; + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-pill); + overflow: hidden; +} + +.lang-toggle__option { + padding: 2px var(--itk-space-2); + font-size: var(--itk-text-xs); + font-weight: 600; + color: var(--itk-slate-500); +} + +.lang-toggle__option:hover { + text-decoration: none; + background-color: var(--itk-slate-100); +} + +.lang-toggle__option--active { + background-color: var(--itk-blue); + color: #fff; +} + +.user-menu__section .lang-toggle { + margin-top: 6px; +} + +.page { + max-width: var(--itk-container); + margin: 0 auto; + padding: var(--itk-space-6) var(--itk-space-5) var(--itk-space-8); +} + +.page__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--itk-space-4); + margin-bottom: var(--itk-space-6); + flex-wrap: wrap; +} + +.page__title { + font-size: var(--itk-text-2xl); + font-weight: 700; +} + +.page__subtitle { + color: var(--itk-slate-500); + margin-top: var(--itk-space-1); +} + +.page__actions { + display: flex; + gap: var(--itk-space-3); + flex-wrap: wrap; +} + +.breadcrumb { + font-size: var(--itk-text-sm); + color: var(--itk-slate-500); + margin-bottom: var(--itk-space-3); +} + +.btn { + display: inline-flex; + align-items: center; + gap: var(--itk-space-2); + border: 1px solid transparent; + border-radius: var(--itk-radius-2); + padding: var(--itk-space-2) var(--itk-space-4); + font: inherit; + font-size: var(--itk-text-sm); + font-weight: 600; + line-height: 1.2; + cursor: pointer; + text-decoration: none; + transition: + background-color 0.15s, + border-color 0.15s, + color 0.15s; + white-space: nowrap; +} + +.btn:hover { + text-decoration: none; +} + +.btn--primary { + background-color: var(--itk-primary); + color: #fff; +} + +.btn--primary:hover { + background-color: var(--itk-primary-hover); + color: #fff; +} + +.btn--ghost { + background-color: var(--itk-paper); + border-color: var(--itk-slate-200); + color: var(--itk-ink); +} + +.btn--ghost:hover { + background-color: var(--itk-slate-100); + color: var(--itk-ink); +} + +.btn--danger { + background-color: var(--itk-paper); + border-color: color-mix( + in srgb, + var(--itk-danger) 40%, + var(--itk-slate-200) + ); + color: var(--itk-danger); +} + +.btn--danger:hover { + background-color: var(--itk-danger); + color: #fff; +} + +.btn--sm { + padding: var(--itk-space-1) var(--itk-space-3); + font-size: var(--itk-text-xs); +} + +.btn--block { + width: 100%; + justify-content: center; +} + +.card { + background-color: var(--itk-surface); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-3); + box-shadow: var(--itk-shadow-1); +} + +.card__body { + padding: var(--itk-space-5); +} + +.card__header { + padding: var(--itk-space-4) var(--itk-space-5); + border-bottom: 1px solid var(--itk-slate-100); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--itk-space-3); +} + +.card__title { + font-size: var(--itk-text-md); + font-weight: 600; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--itk-space-4); + margin-bottom: var(--itk-space-6); +} + +.stat-card { + background-color: var(--itk-surface); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-3); + box-shadow: var(--itk-shadow-1); + padding: var(--itk-space-5); + border-top: 3px solid var(--itk-blue); +} + +.stat-card--accent { + border-top-color: var(--itk-red); +} + +.stat-card--green { + border-top-color: var(--itk-green); +} + +.stat-card--lime { + border-top-color: var(--itk-lime); +} + +.stat-card__label { + font-size: var(--itk-text-sm); + color: var(--itk-slate-500); + font-weight: 500; +} + +.stat-card__value { + font-size: var(--itk-text-2xl); + font-weight: 700; + line-height: 1.1; + margin-top: var(--itk-space-2); + font-feature-settings: "tnum"; +} + +.stat-card__meta { + font-size: var(--itk-text-xs); + color: var(--itk-slate-500); + margin-top: var(--itk-space-1); +} + +.dashboard-grid { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: var(--itk-space-5); + align-items: start; +} + +@media (max-width: 900px) { + .dashboard-grid { + grid-template-columns: 1fr; + } +} + +.status-bars { + display: flex; + flex-direction: column; + gap: var(--itk-space-3); + padding: var(--itk-space-5); +} + +.status-bar__head { + display: flex; + justify-content: space-between; + font-size: var(--itk-text-sm); + margin-bottom: var(--itk-space-1); +} + +.status-bar__track { + height: 8px; + background-color: var(--itk-slate-100); + border-radius: var(--itk-radius-pill); + overflow: hidden; +} + +.status-bar__fill { + height: 100%; + background-color: var(--itk-blue); + border-radius: var(--itk-radius-pill); +} + +.recent-list { + display: flex; + flex-direction: column; +} + +.recent-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--itk-space-3); + padding: var(--itk-space-3) var(--itk-space-5); + border-bottom: 1px solid var(--itk-slate-100); +} + +.recent-item:last-child { + border-bottom: none; +} + +.recent-item__title { + font-weight: 600; + font-size: var(--itk-text-sm); +} + +.recent-item__meta { + font-size: var(--itk-text-xs); + color: var(--itk-slate-500); +} + +.table-wrap { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: var(--itk-text-sm); +} + +.table th, +.table td { + text-align: left; + padding: var(--itk-space-3) var(--itk-space-4); + border-bottom: 1px solid var(--itk-slate-100); + vertical-align: middle; +} + +.table thead th { + font-size: var(--itk-text-xs); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--itk-slate-500); + font-weight: 600; + background-color: var(--itk-slate-50); + border-bottom: 1px solid var(--itk-slate-200); + white-space: nowrap; +} + +.table tbody tr:hover { + background-color: var(--itk-slate-50); +} + +.table td.is-numeric, +.table th.is-numeric { + text-align: right; + font-feature-settings: "tnum"; +} + +.table__sort { + color: var(--itk-slate-500); + display: inline-flex; + align-items: center; + gap: 4px; +} + +.table__sort:hover { + color: var(--itk-ink); + text-decoration: none; +} + +.table__sort.is-active { + color: var(--itk-blue); +} + +.cell-title { + font-weight: 600; + color: var(--itk-ink); +} + +.cell-actions { + display: flex; + gap: var(--itk-space-2); + justify-content: flex-end; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px var(--itk-space-2); + border-radius: var(--itk-radius-pill); + font-size: var(--itk-text-xs); + font-weight: 600; + background-color: var(--itk-slate-100); + color: var(--itk-slate-700); + white-space: nowrap; +} + +.badge--published { + background-color: color-mix(in srgb, var(--itk-green) 16%, transparent); + color: var(--itk-green); +} + +.badge--draft { + background-color: var(--itk-slate-100); + color: var(--itk-slate-500); +} + +.badge--status-idea { + background-color: color-mix(in srgb, var(--itk-slate-500) 18%, transparent); + color: var(--itk-slate-700); +} + +.badge--status-planned { + background-color: color-mix(in srgb, var(--itk-cyan) 18%, transparent); + color: var(--itk-blue); +} + +.badge--status-active { + background-color: color-mix(in srgb, var(--itk-blue) 16%, transparent); + color: var(--itk-blue); +} + +.badge--status-on_hold { + background-color: color-mix(in srgb, var(--itk-warning) 18%, transparent); + color: var(--itk-warning); +} + +.badge--status-completed { + background-color: color-mix(in srgb, var(--itk-green) 16%, transparent); + color: var(--itk-green); +} + +.badge--status-cancelled { + background-color: color-mix(in srgb, var(--itk-red) 16%, transparent); + color: var(--itk-red); +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: var(--itk-space-1); +} + +.tag { + display: inline-block; + padding: 2px var(--itk-space-2); + border-radius: var(--itk-radius-2); + font-size: var(--itk-text-xs); + background-color: var(--itk-slate-100); + color: var(--itk-slate-700); +} + +.filters { + background-color: var(--itk-surface); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-3); + box-shadow: var(--itk-shadow-1); + padding: var(--itk-space-5); + margin-bottom: var(--itk-space-5); +} + +.filters__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--itk-space-4); +} + +.filters__actions { + display: flex; + gap: var(--itk-space-3); + margin-top: var(--itk-space-4); + flex-wrap: wrap; +} + +.filters__search { + grid-column: 1 / -1; +} + +.form { + display: flex; + flex-direction: column; + gap: var(--itk-space-5); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--itk-space-4); +} + +.form-section { + background-color: var(--itk-surface); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-3); + box-shadow: var(--itk-shadow-1); + padding: var(--itk-space-5); +} + +.form-section__title { + font-size: var(--itk-text-md); + font-weight: 600; + margin-bottom: var(--itk-space-4); + padding-bottom: var(--itk-space-3); + border-bottom: 1px solid var(--itk-slate-100); +} + +.form-row { + display: flex; + flex-direction: column; + gap: var(--itk-space-2); + margin-bottom: var(--itk-space-4); +} + +.form-row:last-child { + margin-bottom: 0; +} + +.form-row > label { + font-size: var(--itk-text-sm); + font-weight: 600; + color: var(--itk-slate-700); +} + +.form-row .help-text, +.form-row .form-help { + font-size: var(--itk-text-xs); + color: var(--itk-slate-500); +} + +input[type="text"], +input[type="email"], +input[type="search"], +input[type="tel"], +input[type="url"], +input[type="number"], +input[type="date"], +input[type="password"], +select, +textarea { + width: 100%; + font: inherit; + font-size: var(--itk-text-sm); + color: var(--itk-ink); + background-color: var(--itk-paper); + border: 1px solid var(--itk-slate-300); + border-radius: var(--itk-radius-2); + padding: var(--itk-space-2) var(--itk-space-3); + transition: + border-color 0.15s, + box-shadow 0.15s; +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--itk-blue); + box-shadow: var(--itk-focus); +} + +textarea { + resize: vertical; + min-height: 80px; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: var(--itk-space-2); +} + +.checkbox-row input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--itk-blue); +} + +.choice-expanded { + display: flex; + flex-wrap: wrap; + gap: var(--itk-space-3); +} + +.choice-expanded label { + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.collection { + display: flex; + flex-direction: column; + gap: var(--itk-space-3); +} + +.collection__item { + display: flex; + gap: var(--itk-space-3); + align-items: flex-start; + padding: var(--itk-space-3); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-2); + background-color: var(--itk-slate-50); +} + +.collection__item .form-grid { + flex: 1; +} + +.collection__item .form-row { + margin-bottom: 0; +} + +.collection__fields { + flex: 1; +} + +.form-actions { + display: flex; + gap: var(--itk-space-3); + align-items: center; + flex-wrap: wrap; +} + +.form-errors { + list-style: none; + color: var(--itk-danger); + font-size: var(--itk-text-sm); + margin-top: var(--itk-space-1); +} + +.detail-grid { + display: grid; + grid-template-columns: 1.5fr 1fr; + gap: var(--itk-space-5); + align-items: start; +} + +@media (max-width: 900px) { + .detail-grid { + grid-template-columns: 1fr; + } +} + +.detail-list { + display: grid; + grid-template-columns: minmax(140px, 200px) 1fr; + gap: var(--itk-space-3) var(--itk-space-4); +} + +.detail-list dt { + font-size: var(--itk-text-sm); + color: var(--itk-slate-500); + font-weight: 600; +} + +.detail-list dd { + font-size: var(--itk-text-sm); +} + +.prose { + white-space: pre-wrap; +} + +.detail-subhead { + font-size: var(--itk-text-sm); + margin-top: var(--itk-space-4); +} + +.link-list { + list-style: none; +} + +.contact-card { + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-2); + padding: var(--itk-space-3) var(--itk-space-4); + margin-bottom: var(--itk-space-3); +} + +.contact-card__name { + font-weight: 600; +} + +.contact-card__meta { + font-size: var(--itk-text-sm); + color: var(--itk-slate-500); +} + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--itk-space-3); + margin-top: var(--itk-space-5); + padding: var(--itk-space-4) var(--itk-space-5); + flex-wrap: wrap; +} + +.pagination__pages { + display: flex; + gap: var(--itk-space-1); +} + +.pagination__link { + display: inline-flex; + min-width: 36px; + height: 36px; + align-items: center; + justify-content: center; + padding: 0 var(--itk-space-2); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-2); + background-color: var(--itk-paper); + color: var(--itk-slate-700); + font-size: var(--itk-text-sm); + font-weight: 600; +} + +.pagination__link:hover { + background-color: var(--itk-slate-100); + text-decoration: none; +} + +.pagination__link.is-active { + background-color: var(--itk-blue); + border-color: var(--itk-blue); + color: #fff; +} + +.pagination__info { + font-size: var(--itk-text-sm); + color: var(--itk-slate-500); +} + +.empty-state { + text-align: center; + padding: var(--itk-space-8) var(--itk-space-5); + color: var(--itk-slate-500); +} + +.empty-state__title { + font-size: var(--itk-text-lg); + font-weight: 600; + color: var(--itk-ink); + margin-bottom: var(--itk-space-2); +} + +.flash-messages { + position: fixed; + top: var(--itk-space-5); + right: var(--itk-space-5); + z-index: 300; + display: flex; + flex-direction: column; + gap: var(--itk-space-2); + max-width: 360px; +} + +.flash { + padding: var(--itk-space-3) var(--itk-space-4); + border-radius: var(--itk-radius-2); + box-shadow: var(--itk-shadow-2); + font-size: var(--itk-text-sm); + font-weight: 500; + background-color: var(--itk-paper); + border-left: 4px solid var(--itk-slate-300); +} + +.flash--success { + border-left-color: var(--itk-green); +} + +.flash--error { + border-left-color: var(--itk-red); +} + +.auth { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--itk-space-5); + background: + radial-gradient( + circle at 0% 0%, + color-mix(in srgb, var(--itk-cyan) 14%, transparent), + transparent 40% + ), + radial-gradient( + circle at 100% 100%, + color-mix(in srgb, var(--itk-blue) 14%, transparent), + transparent 40% + ), + var(--itk-bg); +} + +.auth__card { + width: 100%; + max-width: 400px; + background-color: var(--itk-paper); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-4); + box-shadow: var(--itk-shadow-3); + padding: var(--itk-space-7); +} + +.auth__brand { + display: flex; + align-items: center; + gap: var(--itk-space-2); + font-weight: 700; + font-size: var(--itk-text-md); + margin-bottom: var(--itk-space-2); +} + +.auth__title { + font-size: var(--itk-text-xl); + margin-bottom: var(--itk-space-6); +} + +.auth .form-row { + margin-bottom: var(--itk-space-4); +} + +.admin-layout { + display: grid; + grid-template-columns: 220px 1fr; + max-width: var(--itk-container); + margin: 0 auto; + gap: var(--itk-space-6); + padding: var(--itk-space-6) var(--itk-space-5) var(--itk-space-8); +} + +@media (max-width: 800px) { + .admin-layout { + grid-template-columns: 1fr; + } +} + +.admin-sidebar { + display: flex; + flex-direction: column; + gap: var(--itk-space-1); +} + +.admin-sidebar__link { + padding: var(--itk-space-2) var(--itk-space-3); + border-radius: var(--itk-radius-2); + font-size: var(--itk-text-sm); + font-weight: 500; + color: var(--itk-slate-700); +} + +.admin-sidebar__link:hover, +.admin-sidebar__link.is-active { + background-color: var(--itk-slate-100); + color: var(--itk-ink); + text-decoration: none; +} + +.muted { + color: var(--itk-slate-500); +} + +.stack { + display: flex; + flex-direction: column; + gap: var(--itk-space-5); +} + +.image-grid { + display: flex; + flex-wrap: wrap; + gap: var(--itk-space-3); +} + +.image-thumb img { + height: 96px; + width: auto; + border-radius: var(--itk-radius-2); + border: 1px solid var(--itk-slate-200); + display: block; +} + +.file-list { + list-style: none; + display: flex; + flex-direction: column; + gap: var(--itk-space-2); + margin-top: var(--itk-space-3); +} + +.ts-wrapper .ts-control { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--itk-space-1); + width: 100%; + min-height: 38px; + padding: 3px var(--itk-space-3); + font-size: var(--itk-text-sm); + color: var(--itk-ink); + background-color: var(--itk-paper); + border: 1px solid var(--itk-slate-300); + border-radius: var(--itk-radius-2); + box-shadow: none; + transition: + border-color 0.15s, + box-shadow 0.15s; +} + +.ts-wrapper.focus .ts-control { + border-color: var(--itk-blue); + box-shadow: var(--itk-focus); +} + +.ts-wrapper .ts-control > input { + flex: 1 1 auto; + width: auto; + min-width: 6rem; + margin: 0; + padding: var(--itk-space-1) 0; + border: none; + background: transparent; + box-shadow: none; + font: inherit; + font-size: var(--itk-text-sm); + color: var(--itk-ink); +} + +.ts-wrapper .ts-control > input:focus { + outline: none; + border: none; + box-shadow: none; +} + +.ts-wrapper .ts-control .item { + display: inline-flex; + align-items: center; + gap: var(--itk-space-1); + padding: 2px var(--itk-space-2); + font-size: var(--itk-text-xs); + font-weight: 500; + color: var(--itk-blue); + background-color: color-mix(in srgb, var(--itk-blue) 12%, transparent); + border-radius: var(--itk-radius-pill); +} + +.ts-wrapper .ts-control .item .remove { + padding: 0 2px; + border: none; + color: inherit; + opacity: 0.6; +} + +.ts-wrapper .ts-control .item .remove:hover { + background: transparent; + opacity: 1; +} + +.ts-dropdown { + margin-top: var(--itk-space-1); + border: 1px solid var(--itk-slate-200); + border-radius: var(--itk-radius-2); + background-color: var(--itk-paper); + box-shadow: var(--itk-shadow-2); + font-size: var(--itk-text-sm); + color: var(--itk-ink); + overflow: hidden; +} + +.ts-dropdown .option { + padding: var(--itk-space-2) var(--itk-space-3); +} + +.ts-dropdown .active { + background-color: color-mix(in srgb, var(--itk-blue) 10%, transparent); + color: var(--itk-ink); +} + +.ts-dropdown .no-results { + padding: var(--itk-space-2) var(--itk-space-3); + color: var(--itk-slate-500); +} diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..d8d530e --- /dev/null +++ b/bin/console @@ -0,0 +1,21 @@ +#!/usr/bin/env php + doctrine/doctrine-bundle ### + database: + ports: + - "5432" +###< doctrine/doctrine-bundle ### diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..991eb8d --- /dev/null +++ b/composer.json @@ -0,0 +1,118 @@ +{ + "name": "itk-dev/itk-project-database", + "description": "Symfony app for registering and browsing municipal initiatives.", + "license": "proprietary", + "type": "project", + "require": { + "php": ">=8.4", + "ext-ctype": "*", + "ext-iconv": "*", + "doctrine/doctrine-bundle": "^3.2", + "doctrine/doctrine-migrations-bundle": "^4.0", + "doctrine/orm": "^3.6", + "league/csv": "^9.28", + "symfony/asset": "~8.1.0", + "symfony/asset-mapper": "~8.1.0", + "symfony/console": "~8.1.0", + "symfony/dotenv": "~8.1.0", + "symfony/flex": "^2", + "symfony/form": "~8.1.0", + "symfony/framework-bundle": "~8.1.0", + "symfony/rate-limiter": "~8.1.0", + "symfony/runtime": "~8.1.0", + "symfony/security-bundle": "~8.1.0", + "symfony/translation": "~8.1.0", + "symfony/twig-bundle": "~8.1.0", + "symfony/validator": "~8.1.0", + "symfony/yaml": "~8.1.0", + "twig/extra-bundle": "^2.12 || ^3.0", + "twig/twig": "^2.12 || ^3.0", + "vich/uploader-bundle": "^2.9" + }, + "require-dev": { + "doctrine/doctrine-fixtures-bundle": "^4.3", + "ergebnis/composer-normalize": "^2.52", + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-doctrine": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^13.2", + "rregeer/phpunit-coverage-check": "^0.3.1", + "symfony/browser-kit": "~8.1.0", + "symfony/css-selector": "~8.1.0", + "symfony/debug-bundle": "~8.1.0", + "symfony/web-profiler-bundle": "~8.1.0", + "vincentlanglet/twig-cs-fixer": "^4.0", + "zenstruck/foundry": "^2.10" + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*", + "symfony/polyfill-php74": "*", + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*", + "symfony/polyfill-php82": "*", + "symfony/polyfill-php83": "*", + "symfony/polyfill-php84": "*" + }, + "conflict": { + "symfony/symfony": "*" + }, + "minimum-stability": "stable", + "prefer-stable": true, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "php-http/discovery": true, + "phpstan/extension-installer": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "bump-after-update": true, + "sort-packages": true + }, + "extra": { + "symfony": { + "allow-contrib": "true", + "require": "8.1.*" + } + }, + "scripts": { + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ], + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd", + "importmap:install": "symfony-cmd" + }, + "test": "vendor/bin/phpunit", + "test-coverage": [ + "@putenv XDEBUG_MODE=coverage", + "vendor/bin/phpunit --coverage-clover=coverage/clover.xml", + "vendor/bin/coverage-check coverage/clover.xml 100" + ], + "test-setup": [ + "@php bin/console doctrine:database:drop --force --if-exists --env=test", + "@php bin/console doctrine:database:create --if-not-exists --env=test", + "@php bin/console doctrine:migrations:migrate --no-interaction --env=test", + "@php bin/console doctrine:fixtures:load --no-interaction --env=test" + ] + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..50de4a2 --- /dev/null +++ b/composer.lock @@ -0,0 +1,11386 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "ad29920513377f1fcf7144fe62d3ff57", + "packages": [ + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "doctrine/collections", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "php": "^8.1", + "symfony/polyfill-php84": "^1.30" + }, + "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": { + "Doctrine\\Common\\Collections\\": "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" + } + ], + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", + "keywords": [ + "array", + "collections", + "iterators", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/2.6.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%2Fcollections", + "type": "tidelift" + } + ], + "time": "2026-01-15T10:01:58+00:00" + }, + { + "name": "doctrine/dbal", + "version": "4.4.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "61e730f1658814821a85f2402c945f3883407dec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec", + "reference": "61e730f1658814821a85f2402c945f3883407dec", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "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": { + "Doctrine\\DBAL\\": "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" + } + ], + "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": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/4.4.3" + }, + "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": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "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": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "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/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "doctrine/doctrine-bundle", + "version": "3.2.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineBundle.git", + "reference": "75f1bf75d0ba099f23e7d43ebd804df5bec58c29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/75f1bf75d0ba099f23e7d43ebd804df5bec58c29", + "reference": "75f1bf75d0ba099f23e7d43ebd804df5bec58c29", + "shasum": "" + }, + "require": { + "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" + }, + "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": { + "Doctrine\\Bundle\\DoctrineBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "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": "Symfony DoctrineBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "database", + "dbal", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineBundle/issues", + "source": "https://github.com/doctrine/DoctrineBundle/tree/3.2.4" + }, + "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": "doctrine/doctrine-migrations-bundle", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", + "reference": "20505da78735744fb4a42a3bb9a416b345ad6f7c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/20505da78735744fb4a42a3bb9a416b345ad6f7c", + "reference": "20505da78735744fb4a42a3bb9a416b345ad6f7c", + "shasum": "" + }, + "require": { + "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": { + "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": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\MigrationsBundle\\": "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 DoctrineMigrationsBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "dbal", + "migrations", + "schema" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/4.0.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%2Fdoctrine-migrations-bundle", + "type": "tidelift" + } + ], + "time": "2025-12-05T08:14:38+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "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 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": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.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%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-29T07:11:08+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "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": { + "Doctrine\\Inflector\\": "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" + } + ], + "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": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.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%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", + "shasum": "" + }, + "require": { + "php": "^8.4" + }, + "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": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.1.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%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2026-01-05T06:47:08+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "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": { + "Doctrine\\Common\\Lexer\\": "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": "Johannes Schmitt", + "email": "schmittjoh@gmail.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": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.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%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "doctrine/migrations", + "version": "3.9.7", + "source": { + "type": "git", + "url": "https://github.com/doctrine/migrations.git", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "shasum": "" + }, + "require": { + "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": { + "doctrine/orm": "<2.12 || >=4" + }, + "require-dev": { + "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": { + "Doctrine\\Migrations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Michael Simonson", + "email": "contact@mikesimonson.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": [ + "database", + "dbal", + "migrations" + ], + "support": { + "issues": "https://github.com/doctrine/migrations/issues", + "source": "https://github.com/doctrine/migrations/tree/3.9.7" + }, + "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%2Fmigrations", + "type": "tidelift" + } + ], + "time": "2026-04-23T19:33:20+00:00" + }, + { + "name": "doctrine/orm", + "version": "3.6.7", + "source": { + "type": "git", + "url": "https://github.com/doctrine/orm.git", + "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/orm/zipball/bc217c0e19c3a9eadfa67697143b87c9ba01272c", + "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c", + "shasum": "" + }, + "require": { + "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": { + "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" + }, + "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": "jms/metadata", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/metadata.git", + "reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/metadata/zipball/554319d2e5f0c5d8ccaeffe755eac924e14da330", + "reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "require-dev": { + "doctrine/cache": "^1.0|^2.0", + "doctrine/coding-standard": "^8.0", + "mikey179/vfsstream": "^1.6.7", + "phpunit/phpunit": "^8.5.42|^9.6.23", + "psr/container": "^1.0|^2.0", + "symfony/cache": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0", + "symfony/dependency-injection": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Metadata\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "Class/method/property metadata management in PHP", + "keywords": [ + "annotations", + "metadata", + "xml", + "yaml" + ], + "support": { + "issues": "https://github.com/schmittjoh/metadata/issues", + "source": "https://github.com/schmittjoh/metadata/tree/2.9.0" + }, + "time": "2025-11-30T20:12:26+00:00" + }, + { + "name": "league/csv", + "version": "9.28.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/csv.git", + "reference": "6582ace29ae09ba5b07049d40ea13eb19c8b5073" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/6582ace29ae09ba5b07049d40ea13eb19c8b5073", + "reference": "6582ace29ae09ba5b07049d40ea13eb19c8b5073", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1.2" + }, + "require-dev": { + "ext-dom": "*", + "ext-xdebug": "*", + "friendsofphp/php-cs-fixer": "^3.92.3", + "phpbench/phpbench": "^1.4.3", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.2", + "phpstan/phpstan-strict-rules": "^1.6.2", + "phpunit/phpunit": "^10.5.16 || ^11.5.22 || ^12.5.4", + "symfony/var-dumper": "^6.4.8 || ^7.4.0 || ^8.0" + }, + "suggest": { + "ext-dom": "Required to use the XMLConverter and the HTMLConverter classes", + "ext-iconv": "Needed to ease transcoding CSV using iconv stream filters", + "ext-mbstring": "Needed to ease transcoding CSV using mb stream filters", + "ext-mysqli": "Requiered to use the package with the MySQLi extension", + "ext-pdo": "Required to use the package with the PDO extension", + "ext-pgsql": "Requiered to use the package with the PgSQL extension", + "ext-sqlite3": "Required to use the package with the SQLite3 extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "League\\Csv\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://github.com/nyamsprod/", + "role": "Developer" + } + ], + "description": "CSV data manipulation made easy in PHP", + "homepage": "https://csv.thephpleague.com", + "keywords": [ + "convert", + "csv", + "export", + "filter", + "import", + "read", + "transform", + "write" + ], + "support": { + "docs": "https://csv.thephpleague.com", + "issues": "https://github.com/thephpleague/csv/issues", + "rss": "https://github.com/thephpleague/csv/releases.atom", + "source": "https://github.com/thephpleague/csv" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2025-12-27T15:18:42+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\\ErrorHandler\\": "" + }, + "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 to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/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", + "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/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^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\\Contracts\\EventDispatcher\\": "" + } + }, + "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 dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-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-01-05T13:30:16+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "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" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "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 basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/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/finder", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "58d2e767a66052c1487356f953445634a8194c64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", + "reference": "58d2e767a66052c1487356f953445634a8194c64", + "shasum": "" + }, + "require": { + "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": { + "composer/semver": "<1.7.2", + "symfony/dotenv": "<5.4" + }, + "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" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Flex\\Flex" + }, + "autoload": { + "psr-4": { + "Symfony\\Flex\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien.potencier@gmail.com" + } + ], + "description": "Composer plugin for Symfony", + "support": { + "issues": "https://github.com/symfony/flex/issues", + "source": "https://github.com/symfony/flex/tree/v2.11.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-29T17:25:22+00:00" + }, + { + "name": "symfony/form", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/form.git", + "reference": "82f3b7834a1fa05ea3ea5dc944a15cd350ce60a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/form/zipball/82f3b7834a1fa05ea3ea5dc944a15cd350ce60a8", + "reference": "82f3b7834a1fa05ea3ea5dc944a15cd350ce60a8", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/options-resolver": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/polyfill-mbstring": "^1.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^8.1" + }, + "conflict": { + "symfony/intl": "<7.4", + "symfony/translation-contracts": "<2.5", + "symfony/validator": "<7.4" + }, + "require-dev": { + "doctrine/collections": "^1.0|^2.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/translation": "^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\\Form\\": "" + }, + "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 to easily create, process and reuse HTML forms", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/form/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/framework-bundle", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "6a0953f4fd8b51db6136c2628af99b7193e63256" + }, + "dist": { + "type": "zip", + "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/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" + }, + "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" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "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 tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/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/http-client", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "68a48e4c31f63fcd1bdff997a85a09e55efe8cdb" + }, + "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" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "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 powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/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/http-client-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "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": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "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 HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-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-03-06T13:17:50+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e", + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "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" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "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": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/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/http-kernel", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", + "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" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "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\\Component\\HttpKernel\\": "" + }, + "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 structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/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-29T08:46:08+00:00" + }, + { + "name": "symfony/mime", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664", + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "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 manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/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/options-resolver", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "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 an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/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/password-hasher", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/password-hasher.git", + "reference": "6934d16beaa4677f2c4584229fff1b51099dd7af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/6934d16beaa4677f2c4584229fff1b51099dd7af", + "reference": "6934d16beaa4677f2c4584229fff1b51099dd7af", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PasswordHasher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides password hashing utilities", + "homepage": "https://symfony.com", + "keywords": [ + "hashing", + "password" + ], + "support": { + "source": "https://github.com/symfony/password-hasher/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/polyfill-deepclone", + "version": "v1.40.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/dca4ccba5f360070b574414dce4c1e7a559844fa", + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa", + "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\\Polyfill\\DeepClone\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "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": "Symfony polyfill for the deepclone extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.40.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-06-12T07:27:17+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "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\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "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": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "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-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "445c90e341fccda10311019cf82ff73bb7343945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945", + "reference": "445c90e341fccda10311019cf82ff73bb7343945", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "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": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.38.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-25T11:52:53+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "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\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + }, + "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-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "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\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "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": "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/polyfill-intl-normalizer/tree/v1.38.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-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "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\\Polyfill\\Mbstring\\": "" + } + }, + "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": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "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-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "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": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "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-26T02:25:22+00:00" + }, + { + "name": "symfony/property-access", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "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 functions to read and write from/to an object or array using a simple string notation", + "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" + }, + "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/property-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "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" + }, + "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", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/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/rate-limiter", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "657d5b948e913b3989d9cf2f79a84e44e454345a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/657d5b948e913b3989d9cf2f79a84e44e454345a", + "reference": "657d5b948e913b3989d9cf2f79a84e44e454345a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/options-resolver": "^7.4|^8.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/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/routing", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "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": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "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 an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/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/runtime", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/runtime.git", + "reference": "b7ea1abe04561e814b3134db0f56c287cedb35cc" + }, + "dist": { + "type": "zip", + "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" + }, + "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\\Runtime\\": "", + "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" + }, + "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": "Enables decoupling PHP applications from global state", + "homepage": "https://symfony.com", + "keywords": [ + "runtime" + ], + "support": { + "source": "https://github.com/symfony/runtime/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/security-bundle", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-bundle.git", + "reference": "0489a6247f729652db9b9ff408f69ac3bee3589e" + }, + "dist": { + "type": "zip", + "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/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/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": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\SecurityBundle\\": "" + }, + "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 tight integration of the Security component into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-bundle/tree/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/security-core", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-core.git", + "reference": "a8239abe61dafdd0c01c0b4019138b2855717f97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-core/zipball/a8239abe61dafdd0c01c0b4019138b2855717f97", + "reference": "a8239abe61dafdd0c01c0b4019138b2855717f97", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/event-dispatcher-contracts": "^2.5|^3", + "symfony/password-hasher": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "require-dev": { + "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/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\\Security\\Core\\": "" + }, + "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": "Symfony Security Component - Core Library", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-core/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/security-csrf", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-csrf.git", + "reference": "c865a8ee0d30b14545d7e5349b8e443f4fa9dc3f" + }, + "dist": { + "type": "zip", + "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/security-core": "^7.4|^8.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Csrf\\": "" + }, + "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": "Symfony Security Component - CSRF Library", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-csrf/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/security-http", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-http.git", + "reference": "e0e6c7b9e80eec37248b92359cbd6938c7086f4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-http/zipball/e0e6c7b9e80eec37248b92359cbd6938c7086f4b", + "reference": "e0e6c7b9e80eec37248b92359cbd6938c7086f4b", + "shasum": "" + }, + "require": { + "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/http-client-contracts": "<3.0" + }, + "require-dev": { + "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\\Security\\Http\\": "" + }, + "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": "Symfony Security Component - HTTP Integration", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-http/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/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "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\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "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 writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-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-03-28T09:44:51+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": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "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 an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/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/translation", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "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 to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/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/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "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": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "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 translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-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-01-05T13:30:16+00:00" + }, + { + "name": "symfony/twig-bridge", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "25bb8c01edaab85e13142f6010df09b990388343" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/25bb8c01edaab85e13142f6010df09b990388343", + "reference": "25bb8c01edaab85e13142f6010df09b990388343", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^3.25" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/form": "<7.4.4|>8.0,<8.0.4", + "symfony/mime": "<7.4.9|>8.0,<8.0.9" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/asset": "^7.4|^8.0", + "symfony/asset-mapper": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/form": "^7.4.4|^8.0.4", + "symfony/html-sanitizer": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/mime": "^7.4.9|^8.0.9", + "symfony/polyfill-intl-icu": "^1.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^7.4|^8.0", + "symfony/security-http": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0", + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "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 Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-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:06:50+00:00" + }, + { + "name": "symfony/twig-bundle", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "b7f4a471a07b8b52174d153e4db12f46954192ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/b7f4a471a07b8b52174d153e4db12f46954192ed", + "reference": "b7f4a471a07b8b52174d153e4db12f46954192ed", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.4.1", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" + }, + "require-dev": { + "symfony/asset": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\TwigBundle\\": "" + }, + "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 tight integration of Twig into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bundle/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/type-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/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/validator", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "b122b2e384fa84166213ce98b887f01a3eea8d94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/b122b2e384fa84166213ce98b887f01a3eea8d94", + "reference": "b122b2e384fa84166213ce98b887f01a3eea8d94", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/lexer": "<1.1", + "symfony/doctrine-bridge": "<7.4", + "symfony/expression-language": "<7.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/string": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/type-info": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "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 to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/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/var-dumper", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", + "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "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 mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/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/var-exporter", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.37" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "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 tools to export, instantiate, hydrate, clone and lazy-load PHP objects", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "deep-clone", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/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/yaml", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "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": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/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": "twig/extra-bundle", + "version": "v3.24.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/twig-extra-bundle.git", + "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/framework-bundle": "^5.4|^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.2|^4.0" + }, + "require-dev": { + "league/commonmark": "^2.7", + "symfony/phpunit-bridge": "^6.4|^7.0", + "twig/cache-extra": "^3.0", + "twig/cssinliner-extra": "^3.0", + "twig/html-extra": "^3.0", + "twig/inky-extra": "^3.0", + "twig/intl-extra": "^3.0", + "twig/markdown-extra": "^3.0", + "twig/string-extra": "^3.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Twig\\Extra\\TwigExtraBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Symfony bundle for extra Twig extensions", + "homepage": "https://twig.symfony.com", + "keywords": [ + "bundle", + "extra", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-02-07T08:07:38+00:00" + }, + { + "name": "twig/twig", + "version": "v3.27.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.27.1" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-05-30T17:09:26+00:00" + }, + { + "name": "vich/uploader-bundle", + "version": "v2.9.4", + "source": { + "type": "git", + "url": "https://github.com/dustin10/VichUploaderBundle.git", + "reference": "6d2def18f1ca4e0d950962caa70b381e09a58ac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dustin10/VichUploaderBundle/zipball/6d2def18f1ca4e0d950962caa70b381e09a58ac7", + "reference": "6d2def18f1ca4e0d950962caa70b381e09a58ac7", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^3.0 || ^4.0", + "ext-simplexml": "*", + "jms/metadata": "^2.4", + "php": "^8.1", + "symfony/config": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/event-dispatcher-contracts": "^3.1", + "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/mime": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/property-access": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/string": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "league/flysystem": "<2.0" + }, + "require-dev": { + "dg/bypass-finals": "^1.9", + "doctrine/common": "^3.0", + "doctrine/doctrine-bundle": "^2.7 || ^3.0", + "doctrine/mongodb-odm": "^2.4", + "doctrine/orm": "^2.13 || ^3.0", + "ext-sqlite3": "*", + "knplabs/knp-gaufrette-bundle": "dev-master", + "league/flysystem-bundle": "^2.4 || ^3.0", + "league/flysystem-memory": "^2.0 || ^3.0", + "matthiasnoback/symfony-dependency-injection-test": "^5.1 || ^6.0", + "mikey179/vfsstream": "^1.6.11", + "phpunit/phpunit": "^10.5 || ^11.5 || ^12.2", + "symfony/asset": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/browser-kit": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/doctrine-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/dom-crawler": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/form": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/phpunit-bridge": "^7.3", + "symfony/security-csrf": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/translation": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/twig-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/twig-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/validator": "^5.4.22 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-dumper": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "doctrine/doctrine-bundle": "For integration with Doctrine", + "doctrine/mongodb-odm-bundle": "For integration with Doctrine ODM", + "doctrine/orm": "For integration with Doctrine ORM", + "doctrine/phpcr-odm": "For integration with Doctrine PHPCR", + "knplabs/knp-gaufrette-bundle": "For integration with Gaufrette", + "league/flysystem-bundle": "For integration with Flysystem", + "liip/imagine-bundle": "To generate image thumbnails", + "oneup/flysystem-bundle": "For integration with Flysystem", + "symfony/asset": "To generate better links", + "symfony/form": "To handle uploads in forms", + "symfony/yaml": "To use YAML mapping" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Vich\\UploaderBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dustin Dobervich", + "email": "ddobervich@gmail.com" + } + ], + "description": "Ease file uploads attached to entities", + "homepage": "https://github.com/dustin10/VichUploaderBundle", + "keywords": [ + "file uploads", + "upload" + ], + "support": { + "issues": "https://github.com/dustin10/VichUploaderBundle/issues", + "source": "https://github.com/dustin10/VichUploaderBundle/tree/v2.9.4" + }, + "time": "2026-06-01T13:22:20+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "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", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, + { + "name": "ergebnis/composer-normalize", + "version": "2.52.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/composer-normalize.git", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0.0", + "ergebnis/json": "^1.4.0", + "ergebnis/json-normalizer": "^4.9.0", + "ergebnis/json-printer": "^3.7.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "localheinz/diff": "^1.3.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "composer/composer": "^2.9.8", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.62.1", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.11", + "phpunit/phpunit": "^9.6.33", + "rector/rector": "^2.4.3", + "symfony/filesystem": "^5.4.41" + }, + "type": "composer-plugin", + "extra": { + "class": "Ergebnis\\Composer\\Normalize\\NormalizePlugin", + "branch-alias": { + "dev-main": "2.52-dev" + }, + "plugin-optional": true, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Composer\\Normalize\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a composer plugin for normalizing composer.json.", + "homepage": "https://github.com/ergebnis/composer-normalize", + "keywords": [ + "composer", + "normalize", + "normalizer", + "plugin" + ], + "support": { + "issues": "https://github.com/ergebnis/composer-normalize/issues", + "security": "https://github.com/ergebnis/composer-normalize/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/composer-normalize" + }, + "time": "2026-05-15T15:39:24+00:00" + }, + { + "name": "ergebnis/json", + "version": "1.6.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json.git", + "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json/zipball/7b56d2b5d9e897e75b43e2e753075a0904c921b1", + "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpstan-rules": "^2.11.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.6", + "phpunit/phpunit": "^9.6.24", + "rector/rector": "^2.1.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.7-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a Json value object for representing a valid JSON string.", + "homepage": "https://github.com/ergebnis/json", + "keywords": [ + "json" + ], + "support": { + "issues": "https://github.com/ergebnis/json/issues", + "security": "https://github.com/ergebnis/json/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json" + }, + "time": "2025-09-06T09:08:45+00:00" + }, + { + "name": "ergebnis/json-normalizer", + "version": "4.10.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-normalizer.git", + "reference": "77961faf2c651c3f05977b53c6c68e8434febf62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-normalizer/zipball/77961faf2c651c3f05977b53c6c68e8434febf62", + "reference": "77961faf2c651c3f05977b53c6c68e8434febf62", + "shasum": "" + }, + "require": { + "ergebnis/json": "^1.2.0", + "ergebnis/json-pointer": "^3.4.0", + "ergebnis/json-printer": "^3.5.0", + "ergebnis/json-schema-validator": "^4.2.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "composer/semver": "^3.4.3", + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.19", + "rector/rector": "^1.2.10" + }, + "suggest": { + "composer/semver": "If you want to use ComposerJsonNormalizer or VersionConstraintNormalizer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.11-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\Normalizer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides generic and vendor-specific normalizers for normalizing JSON documents.", + "homepage": "https://github.com/ergebnis/json-normalizer", + "keywords": [ + "json", + "normalizer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-normalizer/issues", + "security": "https://github.com/ergebnis/json-normalizer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-normalizer" + }, + "time": "2025-09-06T09:18:13+00:00" + }, + { + "name": "ergebnis/json-pointer", + "version": "3.8.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-pointer.git", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/data-provider": "^3.6.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.16.0", + "fakerphp/faker": "^1.24.1", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.46", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.8-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\Pointer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides an abstraction of a JSON pointer.", + "homepage": "https://github.com/ergebnis/json-pointer", + "keywords": [ + "RFC6901", + "json", + "pointer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-pointer/issues", + "security": "https://github.com/ergebnis/json-pointer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-pointer" + }, + "time": "2026-04-07T14:52:13+00:00" + }, + { + "name": "ergebnis/json-printer", + "version": "3.8.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-printer.git", + "reference": "211d73fc7ec6daf98568ee6ed6e6d133dee8503e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-printer/zipball/211d73fc7ec6daf98568ee6ed6e6d133dee8503e", + "reference": "211d73fc7ec6daf98568ee6ed6e6d133dee8503e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.1", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.21", + "rector/rector": "^1.2.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.9-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\Printer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a JSON printer, allowing for flexible indentation.", + "homepage": "https://github.com/ergebnis/json-printer", + "keywords": [ + "formatter", + "json", + "printer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-printer/issues", + "security": "https://github.com/ergebnis/json-printer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-printer" + }, + "time": "2025-09-06T09:59:26+00:00" + }, + { + "name": "ergebnis/json-schema-validator", + "version": "4.5.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-schema-validator.git", + "reference": "b739527a480a9e3651360ad351ea77e7e9019df2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-schema-validator/zipball/b739527a480a9e3651360ad351ea77e7e9019df2", + "reference": "b739527a480a9e3651360ad351ea77e7e9019df2", + "shasum": "" + }, + "require": { + "ergebnis/json": "^1.2.0", + "ergebnis/json-pointer": "^3.4.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.20", + "rector/rector": "^1.2.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.6-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\SchemaValidator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a JSON schema validator, building on top of justinrainbow/json-schema.", + "homepage": "https://github.com/ergebnis/json-schema-validator", + "keywords": [ + "json", + "schema", + "validator" + ], + "support": { + "issues": "https://github.com/ergebnis/json-schema-validator/issues", + "security": "https://github.com/ergebnis/json-schema-validator/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-schema-validator" + }, + "time": "2025-09-06T11:37:35+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.8", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "4140023f552ff02346df9b1329742532166f677f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/4140023f552ff02346df9b1329742532166f677f", + "reference": "4140023f552ff02346df9b1329742532166f677f", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.9.0", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.8" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-06-16T09:52:26+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "6.10.0", + "source": { + "type": "git", + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "shasum": "" + }, + "require": { + "ext-json": "*", + "marc-mabe/php-enum": "^4.4", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "dev-main", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/jsonrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" + }, + "time": "2026-06-16T20:50:26+00:00" + }, + { + "name": "localheinz/diff", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/localheinz/diff.git", + "reference": "33bd840935970cda6691c23fc7d94ae764c0734c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/localheinz/diff/zipball/33bd840935970cda6691c23fc7d94ae764c0734c", + "reference": "33bd840935970cda6691c23fc7d94ae764c0734c", + "shasum": "" + }, + "require": { + "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5.0 || ^8.5.23", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Fork of sebastian/diff for use with ergebnis/composer-normalize", + "homepage": "https://github.com/localheinz/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/localheinz/diff/issues", + "source": "https://github.com/localheinz/diff/tree/1.3.0" + }, + "time": "2025-08-30T09:44:18+00:00" + }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.2", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + }, + "time": "2025-09-14T11:18:39+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/extension-installer", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + }, + "time": "2024-09-04T20:21:43+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.2", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-06-05T09:00:01+00:00" + }, + { + "name": "phpstan/phpstan-doctrine", + "version": "2.0.27", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-doctrine.git", + "reference": "39b4ca45a07cdd6366eeefa2f7a993cddf3b9f9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/39b4ca45a07cdd6366eeefa2f7a993cddf3b9f9f", + "reference": "39b4ca45a07cdd6366eeefa2f7a993cddf3b9f9f", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.2.2" + }, + "conflict": { + "doctrine/collections": "<1.0", + "doctrine/common": "<2.7", + "doctrine/mongodb-odm": "<1.2", + "doctrine/orm": "<2.5", + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "cache/array-adapter": "^1.1", + "composer/semver": "^3.3.2", + "cweagans/composer-patches": "^1.7.3", + "doctrine/annotations": "^2.0", + "doctrine/collections": "^1.6 || ^2.1", + "doctrine/common": "^2.7 || ^3.0", + "doctrine/dbal": "^3.3.8", + "doctrine/lexer": "^2.0 || ^3.0", + "doctrine/mongodb-odm": "^2.4.3", + "doctrine/orm": "^2.16.0", + "doctrine/persistence": "^2.2.1 || ^3.4.3", + "gedmo/doctrine-extensions": "^3.8", + "nesbot/carbon": "^2.49", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6.20", + "ramsey/uuid": "^4.2", + "shipmonk/name-collision-detector": "^2.1", + "symfony/cache": "^5.4", + "symfony/uid": "^5.4 || ^6.4 || ^7.3" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Doctrine extensions for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-doctrine/issues", + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.27" + }, + "time": "2026-06-10T10:39:35+00:00" + }, + { + "name": "phpstan/phpstan-symfony", + "version": "2.0.20", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-symfony.git", + "reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/53f1a6462dbe71fad36ce054caf5e1b725b740fd", + "reference": "53f1a6462dbe71fad36ce054caf5e1b725b740fd", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.13" + }, + "conflict": { + "symfony/framework-bundle": "<3.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "psr/container": "1.1.2", + "symfony/config": "^5.4 || ^6.1", + "symfony/console": "^5.4 || ^6.1", + "symfony/dependency-injection": "^5.4 || ^6.1", + "symfony/form": "^5.4 || ^6.1", + "symfony/framework-bundle": "^5.4 || ^6.1", + "symfony/http-foundation": "^5.4 || ^6.1", + "symfony/messenger": "^5.4", + "symfony/polyfill-php80": "^1.24", + "symfony/serializer": "^5.4", + "symfony/service-contracts": "^2.2.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lukáš Unger", + "email": "looky.msc@gmail.com", + "homepage": "https://lookyman.net" + } + ], + "description": "Symfony Framework extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-symfony/issues", + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.20" + }, + "time": "2026-06-16T09:17:35+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "14.2.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/10d7da3628a99289cdf4c662dd7f0d73f1baec83", + "reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.0" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "14.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-08T11:50:38+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/60da0ff1e10a0f72ee18a24117ec3b613a346bba", + "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.2.2", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.3.0", + "sebastian/diff": "^9.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.0", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.2-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.1" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-06-15T13:14:22+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "rregeer/phpunit-coverage-check", + "version": "0.3.1", + "source": { + "type": "git", + "url": "https://github.com/richardregeer/phpunit-coverage-check.git", + "reference": "9618fa74477fbc448c1b0599bef5153d170094bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/richardregeer/phpunit-coverage-check/zipball/9618fa74477fbc448c1b0599bef5153d170094bd", + "reference": "9618fa74477fbc448c1b0599bef5153d170094bd", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "bin": [ + "bin/coverage-check" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Richard Regeer", + "email": "rich2309@gmail.com" + } + ], + "description": "Check the code coverage using the clover report of phpunit", + "keywords": [ + "ci", + "code coverage", + "php", + "phpunit", + "testing", + "unittest" + ], + "support": { + "issues": "https://github.com/richardregeer/phpunit-coverage-check/issues", + "source": "https://github.com/richardregeer/phpunit-coverage-check/tree/0.3.1" + }, + "time": "2019-10-14T07:04:13+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:39:44+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c025fc7604afab3f195fab7cdaf72327331af241", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:06:45+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:04:51+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:41:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-21T11:50:56+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:11:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:23:37+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:47:13+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:51:28+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fee0309275847fefd7636167085e379c1dbf6990" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:49:11+00:00" + }, + { + "name": "sebastian/version", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:52+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/74e18e582cdda0eca35f7c74e1e48e62f0ede853", + "reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/dom-crawler": "^7.4|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "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": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/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/css-selector", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/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/debug-bundle", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/debug-bundle.git", + "reference": "2da1f202b38f646dbee032529cfd8e727cd12cd1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/2da1f202b38f646dbee032529cfd8e727cd12cd1", + "reference": "2da1f202b38f646dbee032529cfd8e727cd12cd1", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.4.1", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "require-dev": { + "symfony/web-profiler-bundle": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\DebugBundle\\": "" + }, + "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 tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/debug-bundle/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/dom-crawler", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/77ca351474ea018daba5f2e473cbf1b9b8e72ac6", + "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "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 DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/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/process", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "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": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/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/web-profiler-bundle", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-profiler-bundle.git", + "reference": "f8ccea08797a511b85a698b0da40e1b9e6461086" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f8ccea08797a511b85a698b0da40e1b9e6461086", + "reference": "f8ccea08797a511b85a698b0da40e1b9e6461086", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.4.1", + "symfony/config": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^8.1", + "symfony/routing": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0" + }, + "conflict": { + "symfony/serializer": "<7.4", + "symfony/workflow": "<7.4" + }, + "require-dev": { + "symfony/browser-kit": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\WebProfilerBundle\\": "" + }, + "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 development tool that gives detailed information about the execution of any request", + "homepage": "https://symfony.com", + "keywords": [ + "dev" + ], + "support": { + "source": "https://github.com/symfony/web-profiler-bundle/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": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "vincentlanglet/twig-cs-fixer", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git", + "reference": "10e0133faf33218ea3380432f3a757ba50e9e17d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/10e0133faf33218ea3380432f3a757ba50e9e17d", + "reference": "10e0133faf33218ea3380432f3a757ba50e9e17d", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0.0", + "ext-ctype": "*", + "php": ">=8.1", + "symfony/console": "^5.4.9 || ^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/string": "^5.4.42 || ^6.4.10 || ~7.0.10 || ^7.1.3 || ^8.0", + "twig/twig": "^3.15", + "webmozart/assert": "^1.10 || ^2.0" + }, + "require-dev": { + "composer/semver": "^3.2.0", + "dereuromark/composer-prefer-lowest": "^0.1.10", + "ergebnis/composer-normalize": "^2.29", + "friendsofphp/php-cs-fixer": "^3.13.0", + "infection/infection": "^0.26.16 || ^0.32.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^9.5.26 || ^11.5.18 || ^12.1.3", + "rector/rector": "^2.0.0", + "shipmonk/composer-dependency-analyser": "^1.6", + "symfony/process": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/twig-bridge": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/ux-twig-component": "^2.2.0", + "twig/cache-extra": "^3.2" + }, + "bin": [ + "bin/twig-cs-fixer" + ], + "type": "coding-standard", + "autoload": { + "psr-4": { + "TwigCsFixer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Vincent Langlet" + } + ], + "description": "A tool to automatically fix Twig code style", + "homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer", + "support": { + "issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues", + "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/VincentLanglet", + "type": "github" + } + ], + "time": "2026-06-15T14:41:51+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" + }, + { + "name": "zenstruck/assert", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/zenstruck/assert.git", + "reference": "1e32d48847d4e82c345112ca226b21a1a792af0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zenstruck/assert/zipball/1e32d48847d4e82c345112ca226b21a1a792af0a", + "reference": "1e32d48847d4e82c345112ca226b21a1a792af0a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9.5.21", + "symfony/phpunit-bridge": "^6.3|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Zenstruck\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + } + ], + "description": "Standalone, lightweight, framework agnostic, test assertion library.", + "homepage": "https://github.com/zenstruck/assert", + "keywords": [ + "assertion", + "phpunit", + "test" + ], + "support": { + "issues": "https://github.com/zenstruck/assert/issues", + "source": "https://github.com/zenstruck/assert/tree/v1.7.0" + }, + "funding": [ + { + "url": "https://github.com/kbond", + "type": "github" + }, + { + "url": "https://github.com/nikophil", + "type": "github" + } + ], + "time": "2025-12-07T01:59:12+00:00" + }, + { + "name": "zenstruck/foundry", + "version": "v2.10.1", + "source": { + "type": "git", + "url": "https://github.com/zenstruck/foundry.git", + "reference": "34cbd882df0fee5a5f9bd2f0c2f69286e283263d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zenstruck/foundry/zipball/34cbd882df0fee5a5f9bd2f0c2f69286e283263d", + "reference": "34cbd882df0fee5a5f9bd2f0c2f69286e283263d", + "shasum": "" + }, + "require": { + "fakerphp/faker": "^1.24", + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/polyfill-php84": "^1.32", + "symfony/polyfill-php85": "^1.33", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4.9|~7.0.9|^7.1.2|^8.0", + "zenstruck/assert": "^1.4" + }, + "conflict": { + "doctrine/cache": "<1.12.1", + "doctrine/persistence": "<2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8", + "dama/doctrine-test-bundle": "^8.0", + "doctrine/collections": "^1.7|^2.0", + "doctrine/common": "^3.2.2", + "doctrine/doctrine-bundle": "^2.10|^3.0", + "doctrine/doctrine-migrations-bundle": "^2.2|^3.0", + "doctrine/mongodb-odm": "^2.4", + "doctrine/mongodb-odm-bundle": "^4.6|^5.0", + "doctrine/orm": "^2.16|^3.0", + "doctrine/persistence": "^2.0|^3.0|^4.0", + "phpunit/phpunit": "^9.5.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/flex": "^2.10", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/maker-bundle": "^1.55", + "symfony/phpunit-bridge": "^6.4.26|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^3.4", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "webmozart/assert": "^1.11" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Zenstruck\\Foundry\\Psalm\\FoundryPlugin" + }, + "symfony": { + "allow-contrib": false + }, + "bamarni-bin": { + "bin-links": true, + "forward-command": false, + "target-directory": "bin/tools" + } + }, + "autoload": { + "files": [ + "src/functions.php", + "src/Persistence/functions.php", + "src/symfony_console.php" + ], + "psr-4": { + "Zenstruck\\Foundry\\": "src/", + "Zenstruck\\Foundry\\Psalm\\": "utils/psalm", + "Zenstruck\\Foundry\\Utils\\Rector\\": "utils/rector/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Bond", + "email": "kevinbond@gmail.com" + }, + { + "name": "Nicolas PHILIPPE", + "email": "nikophil@gmail.com" + } + ], + "description": "A model factory library for creating expressive, auto-completable, on-demand dev/test fixtures with Symfony and Doctrine.", + "homepage": "https://github.com/zenstruck/foundry", + "keywords": [ + "Fixture", + "dev", + "doctrine", + "factory", + "faker", + "symfony", + "test" + ], + "support": { + "issues": "https://github.com/zenstruck/foundry/issues", + "source": "https://github.com/zenstruck/foundry/tree/v2.10.1" + }, + "funding": [ + { + "url": "https://github.com/kbond", + "type": "github" + }, + { + "url": "https://github.com/nikophil", + "type": "github" + } + ], + "time": "2026-05-19T15:59:32+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.4", + "ext-ctype": "*", + "ext-iconv": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 0000000..84cc77d --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,15 @@ + ['all' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], + Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], + Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], + Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true], + Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], + Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], + Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], +]; diff --git a/config/packages/asset_mapper.yaml b/config/packages/asset_mapper.yaml new file mode 100644 index 0000000..f7653e9 --- /dev/null +++ b/config/packages/asset_mapper.yaml @@ -0,0 +1,11 @@ +framework: + asset_mapper: + # The paths to make available to the asset mapper. + paths: + - assets/ + missing_import_mode: strict + +when@prod: + framework: + asset_mapper: + missing_import_mode: warn diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml new file mode 100644 index 0000000..c3eb53d --- /dev/null +++ b/config/packages/cache.yaml @@ -0,0 +1,19 @@ +framework: + cache: + # Unique name of your app: used to compute stable namespaces for cache keys. + #prefix_seed: your_vendor_name/app_name + + # The "app" cache stores to the filesystem by default. + # The data in this cache should persist between deploys. + # Other options include: + + # Redis + #app: cache.adapter.redis + #default_redis_provider: redis://localhost + + # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) + #app: cache.adapter.apcu + + # Namespaced pools use the above "app" backend by default + #pools: + #my.dedicated.cache: null diff --git a/config/packages/csrf.yaml b/config/packages/csrf.yaml new file mode 100644 index 0000000..10f2197 --- /dev/null +++ b/config/packages/csrf.yaml @@ -0,0 +1,5 @@ +# Session-based CSRF protection for forms and logins/logouts. +# Stateless CSRF needs the Stimulus csrf_protection_controller.js to resolve the +# token client-side; this branch ships no Stimulus, so use session-based tokens. +framework: + csrf_protection: true diff --git a/config/packages/debug.yaml b/config/packages/debug.yaml new file mode 100644 index 0000000..54a4821 --- /dev/null +++ b/config/packages/debug.yaml @@ -0,0 +1,5 @@ +when@dev: + debug: + # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. + # See the "server:dump" command to start a new server. + dump_destination: 'tcp://%env(VAR_DUMPER_SERVER)%' diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 0000000..290611c --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,46 @@ +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + + # IMPORTANT: You MUST configure your server version, + # either here or in the DATABASE_URL env var (see .env file) + #server_version: '16' + + profiling_collect_backtrace: '%kernel.debug%' + orm: + validate_xml_mapping: true + naming_strategy: doctrine.orm.naming_strategy.underscore + identity_generation_preferences: + Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity + 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/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 0000000..216c77c --- /dev/null +++ b/config/packages/framework.yaml @@ -0,0 +1,28 @@ +# see https://symfony.com/doc/current/reference/configuration/framework.html +framework: + secret: '%env(APP_SECRET)%' + + # Behind the Traefik -> nginx reverse proxy, trust the forwarded headers so URLs + # are generated with the original https scheme. nginx's real_ip rewrites REMOTE_ADDR + # to the real client, so REMOTE_ADDR is the address to trust here. + trusted_proxies: '%env(TRUSTED_PROXIES)%' + trusted_headers: + [ + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-forwarded-port', + 'x-forwarded-prefix', + ] + + # Note that the session will be started ONLY if you read or write from it. + session: true + + #esi: true + #fragments: true + +when@test: + framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml new file mode 100644 index 0000000..dd31b9d --- /dev/null +++ b/config/packages/property_info.yaml @@ -0,0 +1,3 @@ +framework: + property_info: + with_constructor_extractor: true diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml new file mode 100644 index 0000000..0f34f87 --- /dev/null +++ b/config/packages/routing.yaml @@ -0,0 +1,10 @@ +framework: + router: + # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. + # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands + default_uri: '%env(DEFAULT_URI)%' + +when@prod: + framework: + router: + strict_requirements: null diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 0000000..0cb209d --- /dev/null +++ b/config/packages/security.yaml @@ -0,0 +1,43 @@ +security: + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + + providers: + app_user_provider: + entity: + class: App\Entity\User + property: email + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|assets)/ + security: false + main: + lazy: true + provider: app_user_provider + form_login: + login_path: app_login + check_path: app_login + default_target_path: app_dashboard + enable_csrf: true + logout: + path: app_logout + target: app_login + login_throttling: + max_attempts: 5 + interval: '1 minute' + + access_control: + - { path: ^/login, roles: PUBLIC_ACCESS } + - { path: ^/locale, roles: PUBLIC_ACCESS } + - { path: ^/admin, roles: ROLE_ADMIN } + - { path: ^/, roles: ROLE_USER } + +when@test: + security: + password_hashers: + # Pin a single, fast, deterministic hasher for the test suite so only + # the relevant knob (bcrypt cost) applies. + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: bcrypt + cost: 4 diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml new file mode 100644 index 0000000..44811a8 --- /dev/null +++ b/config/packages/translation.yaml @@ -0,0 +1,9 @@ +framework: + default_locale: da + enabled_locales: [da, en] + translator: + default_path: '%kernel.project_dir%/translations' + fallbacks: + - da + - en + providers: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml new file mode 100644 index 0000000..89525df --- /dev/null +++ b/config/packages/twig.yaml @@ -0,0 +1,8 @@ +twig: + file_name_pattern: '*.twig' + form_themes: + - 'form/fields.html.twig' + +when@test: + twig: + strict_variables: true diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml new file mode 100644 index 0000000..dd47a6a --- /dev/null +++ b/config/packages/validator.yaml @@ -0,0 +1,11 @@ +framework: + validation: + # Enables validator auto-mapping support. + # For instance, basic validation constraints will be inferred from Doctrine's metadata. + #auto_mapping: + # App\Entity\: [] + +when@test: + framework: + validation: + not_compromised_password: false diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml new file mode 100644 index 0000000..ba5a39f --- /dev/null +++ b/config/packages/vich_uploader.yaml @@ -0,0 +1,14 @@ +vich_uploader: + db_driver: orm + + # Files are stored outside the web root and served through an + # authenticated controller (App\Controller\MediaController). + mappings: + initiative_image: + uri_prefix: /media/initiative-images + upload_destination: '%kernel.project_dir%/var/uploads/initiative-images' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + initiative_attachment: + uri_prefix: /media/initiative-attachments + upload_destination: '%kernel.project_dir%/var/uploads/initiative-attachments' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer diff --git a/config/packages/web_profiler.yaml b/config/packages/web_profiler.yaml new file mode 100644 index 0000000..456fc45 --- /dev/null +++ b/config/packages/web_profiler.yaml @@ -0,0 +1,11 @@ +when@dev: + web_profiler: + toolbar: true + + framework: + profiler: true + +when@test: + framework: + profiler: + collect: false diff --git a/config/packages/zenstruck_foundry.yaml b/config/packages/zenstruck_foundry.yaml new file mode 100644 index 0000000..50d8a8f --- /dev/null +++ b/config/packages/zenstruck_foundry.yaml @@ -0,0 +1,9 @@ +# See the full configuration: +# https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html +when@dev: &dev + zenstruck_foundry: + persistence: + # Flush only once per call of `PersistentObjectFactory::create()` + flush_once: true + +when@test: *dev diff --git a/config/preload.php b/config/preload.php new file mode 100644 index 0000000..5ebcdb2 --- /dev/null +++ b/config/preload.php @@ -0,0 +1,5 @@ + doctrine/doctrine-bundle ### + database: + image: postgres:${POSTGRES_VERSION:-16}-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-app} + # You should definitely change the password in production + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} + POSTGRES_USER: ${POSTGRES_USER:-app} + healthcheck: + test: + [ + "CMD", + "pg_isready", + "-d", + "${POSTGRES_DB:-app}", + "-U", + "${POSTGRES_USER:-app}", + ] + timeout: 5s + retries: 5 + start_period: 60s + volumes: + - database_data:/var/lib/postgresql/data:rw + # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! + # - ./docker/db/data:/var/lib/postgresql/data:rw +###< doctrine/doctrine-bundle ### + +volumes: + ###> doctrine/doctrine-bundle ### + database_data: +###< doctrine/doctrine-bundle ### diff --git a/importmap.php b/importmap.php new file mode 100644 index 0000000..122616b --- /dev/null +++ b/importmap.php @@ -0,0 +1,35 @@ + + */ +return [ + 'app' => ['path' => './assets/app.js', 'entrypoint' => true], + '@hotwired/stimulus' => ['version' => '3.2.2'], + 'tom-select' => ['version' => '2.6.1'], + '@orchidjs/sifter' => ['version' => '1.1.0'], + '@orchidjs/unicode-variants' => ['version' => '1.1.2'], + 'tom-select/dist/css/tom-select.default.min.css' => ['version' => '2.6.1', 'type' => 'css'], + 'tom-select/dist/css/tom-select.default.css' => ['version' => '2.6.1', 'type' => 'css'], + 'tom-select/dist/css/tom-select.bootstrap4.css' => ['version' => '2.6.1', 'type' => 'css'], + 'tom-select/dist/css/tom-select.bootstrap5.css' => ['version' => '2.6.1', 'type' => 'css'], +]; diff --git a/migrations/.gitignore b/migrations/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/migrations/Version20260623093956.php b/migrations/Version20260623093956.php new file mode 100644 index 0000000..e683965 --- /dev/null +++ b/migrations/Version20260623093956.php @@ -0,0 +1,68 @@ +addSql('CREATE TABLE contact (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(64) DEFAULT NULL, department VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE initiative (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, category VARCHAR(32) DEFAULT NULL, description LONGTEXT DEFAULT NULL, initiative_type VARCHAR(32) DEFAULT NULL, status VARCHAR(32) DEFAULT NULL, status_additional LONGTEXT DEFAULT NULL, organizational_anchoring VARCHAR(64) DEFAULT NULL, endorsement TINYINT NOT NULL, endorsement_author VARCHAR(32) DEFAULT NULL, budget INT DEFAULT NULL, funding JSON NOT NULL, time_period_start DATE DEFAULT NULL, time_period_end DATE DEFAULT NULL, links JSON NOT NULL, author VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE initiative_strategy (initiative_id INT NOT NULL, term_id INT NOT NULL, INDEX IDX_9FDB07E5AB7D9771 (initiative_id), INDEX IDX_9FDB07E5E2C35FC (term_id), PRIMARY KEY (initiative_id, term_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE initiative_contact (initiative_id INT NOT NULL, contact_id INT NOT NULL, INDEX IDX_6F980462AB7D9771 (initiative_id), INDEX IDX_6F980462E7A1254A (contact_id), PRIMARY KEY (initiative_id, contact_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE initiative_stakeholder (initiative_id INT NOT NULL, term_id INT NOT NULL, INDEX IDX_C97AB0E7AB7D9771 (initiative_id), INDEX IDX_C97AB0E7E2C35FC (term_id), PRIMARY KEY (initiative_id, term_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE initiative_tag (initiative_id INT NOT NULL, term_id INT NOT NULL, INDEX IDX_4FF4E32DAB7D9771 (initiative_id), INDEX IDX_4FF4E32DE2C35FC (term_id), PRIMARY KEY (initiative_id, term_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE initiative_attachment (id INT AUTO_INCREMENT NOT NULL, file_name VARCHAR(255) DEFAULT NULL, original_name VARCHAR(255) DEFAULT NULL, mime_type VARCHAR(255) DEFAULT NULL, size INT DEFAULT NULL, updated_at DATETIME DEFAULT NULL, initiative_id INT NOT NULL, INDEX IDX_2954F892AB7D9771 (initiative_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE initiative_image (id INT AUTO_INCREMENT NOT NULL, image_name VARCHAR(255) DEFAULT NULL, original_name VARCHAR(255) DEFAULT NULL, mime_type VARCHAR(255) DEFAULT NULL, size INT DEFAULT NULL, alt VARCHAR(255) DEFAULT NULL, updated_at DATETIME DEFAULT NULL, initiative_id INT NOT NULL, INDEX IDX_99BF4CA8AB7D9771 (initiative_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE term (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, vocabulary VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL, UNIQUE INDEX uniq_term_name_vocabulary (name, vocabulary), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, name VARCHAR(255) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE initiative_strategy ADD CONSTRAINT FK_9FDB07E5AB7D9771 FOREIGN KEY (initiative_id) REFERENCES initiative (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_strategy ADD CONSTRAINT FK_9FDB07E5E2C35FC FOREIGN KEY (term_id) REFERENCES term (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_contact ADD CONSTRAINT FK_6F980462AB7D9771 FOREIGN KEY (initiative_id) REFERENCES initiative (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_contact ADD CONSTRAINT FK_6F980462E7A1254A FOREIGN KEY (contact_id) REFERENCES contact (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_stakeholder ADD CONSTRAINT FK_C97AB0E7AB7D9771 FOREIGN KEY (initiative_id) REFERENCES initiative (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_stakeholder ADD CONSTRAINT FK_C97AB0E7E2C35FC FOREIGN KEY (term_id) REFERENCES term (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_tag ADD CONSTRAINT FK_4FF4E32DAB7D9771 FOREIGN KEY (initiative_id) REFERENCES initiative (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_tag ADD CONSTRAINT FK_4FF4E32DE2C35FC FOREIGN KEY (term_id) REFERENCES term (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_attachment ADD CONSTRAINT FK_2954F892AB7D9771 FOREIGN KEY (initiative_id) REFERENCES initiative (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE initiative_image ADD CONSTRAINT FK_99BF4CA8AB7D9771 FOREIGN KEY (initiative_id) REFERENCES initiative (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE initiative_strategy DROP FOREIGN KEY FK_9FDB07E5AB7D9771'); + $this->addSql('ALTER TABLE initiative_strategy DROP FOREIGN KEY FK_9FDB07E5E2C35FC'); + $this->addSql('ALTER TABLE initiative_contact DROP FOREIGN KEY FK_6F980462AB7D9771'); + $this->addSql('ALTER TABLE initiative_contact DROP FOREIGN KEY FK_6F980462E7A1254A'); + $this->addSql('ALTER TABLE initiative_stakeholder DROP FOREIGN KEY FK_C97AB0E7AB7D9771'); + $this->addSql('ALTER TABLE initiative_stakeholder DROP FOREIGN KEY FK_C97AB0E7E2C35FC'); + $this->addSql('ALTER TABLE initiative_tag DROP FOREIGN KEY FK_4FF4E32DAB7D9771'); + $this->addSql('ALTER TABLE initiative_tag DROP FOREIGN KEY FK_4FF4E32DE2C35FC'); + $this->addSql('ALTER TABLE initiative_attachment DROP FOREIGN KEY FK_2954F892AB7D9771'); + $this->addSql('ALTER TABLE initiative_image DROP FOREIGN KEY FK_99BF4CA8AB7D9771'); + $this->addSql('DROP TABLE contact'); + $this->addSql('DROP TABLE initiative'); + $this->addSql('DROP TABLE initiative_strategy'); + $this->addSql('DROP TABLE initiative_contact'); + $this->addSql('DROP TABLE initiative_stakeholder'); + $this->addSql('DROP TABLE initiative_tag'); + $this->addSql('DROP TABLE initiative_attachment'); + $this->addSql('DROP TABLE initiative_image'); + $this->addSql('DROP TABLE term'); + $this->addSql('DROP TABLE `user`'); + } +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..1da4602 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,12 @@ +parameters: + level: 6 + paths: + - src + - tests + excludePaths: + - src/Kernel.php + - tests/bootstrap.php + doctrine: + # Required (non-nullable) columns are modelled with nullable properties + # defaulting to null — the conventional Symfony pattern. + allowNullablePropertyForRequiredField: true diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..7a16e16 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + tests + + + + + + src + + + + + src/Kernel.php + src/DataFixtures + src/Story + + + + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + trigger_deprecation + + + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..c0037a8 --- /dev/null +++ b/public/index.php @@ -0,0 +1,9 @@ +addArgument('email', InputArgument::REQUIRED, 'The administrator e-mail (used to sign in)') + ->addArgument('name', InputArgument::OPTIONAL, 'Display name'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + /** @var string $email */ + $email = $input->getArgument('email'); + /** @var string|null $name */ + $name = $input->getArgument('name'); + + $question = (new Question('Password: ')) + ->setHidden(true) + ->setValidator(static function (?string $value): string { + if (null === $value || \strlen($value) < 8) { + throw new \RuntimeException('The password must be at least 8 characters long.'); + } + + return $value; + }); + $plainPassword = $io->askQuestion($question); + + $user = $this->userRepository->findOneBy(['email' => $email]) ?? new User(); + $user->setEmail($email); + $user->setName($name ?? $email); + $user->setRoles(['ROLE_ADMIN']); + $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword)); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + $io->success(sprintf('Administrator "%s" is ready. You can now sign in.', $email)); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/.gitignore b/src/Controller/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php new file mode 100644 index 0000000..42dba92 --- /dev/null +++ b/src/Controller/Admin/UserController.php @@ -0,0 +1,91 @@ +render('admin/users/index.html.twig', [ + 'users' => $users->findBy([], ['email' => 'ASC']), + ]); + } + + #[Route('/new', name: 'admin_user_new', methods: ['GET', 'POST'])] + public function new(Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $hasher): Response + { + $user = new User(); + $user->setRoles(['ROLE_USER']); + $form = $this->createForm(UserType::class, $user, ['require_password' => true]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $plainPassword = (string) $form->get('plainPassword')->getData(); + $user->setPassword($hasher->hashPassword($user, $plainPassword)); + $entityManager->persist($user); + $entityManager->flush(); + $this->addFlash('success', 'flash.user.created'); + + return $this->redirectToRoute('admin_users'); + } + + return $this->render('admin/users/new.html.twig', ['form' => $form]); + } + + #[Route('/{id}/edit', name: 'admin_user_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])] + public function edit(Request $request, User $user, EntityManagerInterface $entityManager, UserPasswordHasherInterface $hasher): Response + { + $form = $this->createForm(UserType::class, $user, ['require_password' => false]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $plainPassword = (string) $form->get('plainPassword')->getData(); + if ('' !== $plainPassword) { + $user->setPassword($hasher->hashPassword($user, $plainPassword)); + } + $entityManager->flush(); + $this->addFlash('success', 'flash.user.updated'); + + return $this->redirectToRoute('admin_users'); + } + + return $this->render('admin/users/edit.html.twig', [ + 'form' => $form, + 'user' => $user, + ]); + } + + #[Route('/{id}/delete', name: 'admin_user_delete', requirements: ['id' => '\d+'], methods: ['POST'])] + public function delete(Request $request, User $user, EntityManagerInterface $entityManager): Response + { + if ($user === $this->getUser()) { + $this->addFlash('error', 'flash.user.cannot_delete_self'); + + return $this->redirectToRoute('admin_users'); + } + + if ($this->isCsrfTokenValid('delete-user-'.$user->getId(), (string) $request->request->get('_token'))) { + $entityManager->remove($user); + $entityManager->flush(); + $this->addFlash('success', 'flash.user.deleted'); + } + + return $this->redirectToRoute('admin_users'); + } +} diff --git a/src/Controller/ContactController.php b/src/Controller/ContactController.php new file mode 100644 index 0000000..2445b55 --- /dev/null +++ b/src/Controller/ContactController.php @@ -0,0 +1,74 @@ +render('contact/index.html.twig', [ + 'contacts' => $contacts->findAllOrdered(), + ]); + } + + #[Route('/contacts/new', name: 'app_contact_new', methods: ['GET', 'POST'])] + public function new(Request $request, EntityManagerInterface $entityManager): Response + { + $contact = new Contact(); + $form = $this->createForm(ContactType::class, $contact); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->persist($contact); + $entityManager->flush(); + $this->addFlash('success', 'flash.contact.created'); + + return $this->redirectToRoute('app_contact_index'); + } + + return $this->render('contact/new.html.twig', ['form' => $form]); + } + + #[Route('/contacts/{id}/edit', name: 'app_contact_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])] + public function edit(Request $request, Contact $contact, EntityManagerInterface $entityManager): Response + { + $form = $this->createForm(ContactType::class, $contact); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager->flush(); + $this->addFlash('success', 'flash.contact.updated'); + + return $this->redirectToRoute('app_contact_index'); + } + + return $this->render('contact/edit.html.twig', [ + 'form' => $form, + 'contact' => $contact, + ]); + } + + #[Route('/contacts/{id}/delete', name: 'app_contact_delete', requirements: ['id' => '\d+'], methods: ['POST'])] + public function delete(Request $request, Contact $contact, EntityManagerInterface $entityManager): Response + { + if ($this->isCsrfTokenValid('delete-contact-'.$contact->getId(), (string) $request->request->get('_token'))) { + $entityManager->remove($contact); + $entityManager->flush(); + $this->addFlash('success', 'flash.contact.deleted'); + } + + return $this->redirectToRoute('app_contact_index'); + } +} diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php new file mode 100644 index 0000000..63fce77 --- /dev/null +++ b/src/Controller/DashboardController.php @@ -0,0 +1,27 @@ +render('dashboard/index.html.twig', [ + 'total' => $initiatives->countAll(), + 'byStatus' => $initiatives->countByStatus(), + 'statuses' => Status::cases(), + 'recent' => $initiatives->findRecent(8), + 'contactCount' => $contacts->count([]), + ]); + } +} diff --git a/src/Controller/InitiativeController.php b/src/Controller/InitiativeController.php new file mode 100644 index 0000000..4bd361b --- /dev/null +++ b/src/Controller/InitiativeController.php @@ -0,0 +1,187 @@ +createForm(InitiativeFilterType::class, $filter); + $form->handleRequest($request); + + $filter->sort = (string) $request->query->get('sort', 'createdAt'); + $filter->direction = (string) $request->query->get('direction', 'DESC'); + + $pagination = $paginator->paginate( + $initiatives->search($filter), + $request->query->getInt('page', 1), + ); + + return $this->render('initiative/index.html.twig', [ + 'form' => $form, + 'pagination' => $pagination, + 'sort' => $filter->sort, + 'direction' => $filter->direction, + ]); + } + + #[Route('/initiatives/export', name: 'app_initiative_export', methods: ['GET'])] + public function export(Request $request, InitiativeRepository $initiatives, TranslatorInterface $translator): StreamedResponse + { + $filter = new InitiativeFilter(); + $this->createForm(InitiativeFilterType::class, $filter)->handleRequest($request); + $filter->sort = (string) $request->query->get('sort', 'createdAt'); + $filter->direction = (string) $request->query->get('direction', 'DESC'); + + $rows = $initiatives->findForExport($filter); + + $translate = static fn (?object $enum): string => $enum instanceof \App\Enum\TranslatableEnum + ? $translator->trans($enum->labelKey()) + : ''; + + $response = new StreamedResponse(function () use ($rows, $translator, $translate): void { + $csv = Writer::createFromStream(fopen('php://output', 'w')); + $csv->insertOne([ + 'id', $translator->trans('initiative.title'), $translator->trans('initiative.status'), + $translator->trans('initiative.category'), $translator->trans('initiative.initiative_type'), + $translator->trans('initiative.organizational_anchoring'), $translator->trans('initiative.endorsement'), + $translator->trans('initiative.endorsement_author'), $translator->trans('initiative.budget'), + $translator->trans('initiative.funding'), $translator->trans('initiative.stakeholders'), + $translator->trans('initiative.strategies'), $translator->trans('initiative.tags'), + $translator->trans('initiative.time_period_start'), $translator->trans('initiative.time_period_end'), + $translator->trans('initiative.contacts'), $translator->trans('initiative.author'), + ]); + + $names = static fn (iterable $items): string => implode(', ', array_map('strval', \is_array($items) ? $items : iterator_to_array($items))); + + foreach ($rows as $row) { + $csv->insertOne([ + $row->getId(), + $row->getTitle(), + $translate($row->getStatus()), + $translate($row->getCategory()), + $translate($row->getInitiativeType()), + $translate($row->getOrganizationalAnchoring()), + $row->isEndorsement() ? $translator->trans('filter.yes') : $translator->trans('filter.no'), + $translate($row->getEndorsementAuthor()), + $row->getBudget(), + implode(', ', array_map($translate, $row->getFunding())), + $names($row->getStakeholders()), + $names($row->getStrategies()), + $names($row->getTags()), + $row->getTimePeriodStart()?->format('Y-m-d'), + $row->getTimePeriodEnd()?->format('Y-m-d'), + $names($row->getContacts()), + $row->getAuthor(), + ]); + } + }); + + $filename = sprintf('initiatives-%s.csv', (new \DateTimeImmutable())->format('Y-m-d')); + $response->headers->set('Content-Type', 'text/csv; charset=utf-8'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); + + return $response; + } + + #[Route('/initiatives/new', name: 'app_initiative_new', methods: ['GET', 'POST'])] + public function new(Request $request, EntityManagerInterface $entityManager): Response + { + $initiative = new Initiative(); + $form = $this->createForm(InitiativeType::class, $initiative); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->removeEmptyMedia($initiative); + $entityManager->persist($initiative); + $entityManager->flush(); + + $this->addFlash('success', 'flash.initiative.created'); + + return $this->redirectToRoute('app_initiative_show', ['id' => $initiative->getId()]); + } + + return $this->render('initiative/new.html.twig', [ + 'form' => $form, + 'initiative' => $initiative, + ]); + } + + #[Route('/initiatives/{id}', name: 'app_initiative_show', requirements: ['id' => '\d+'], methods: ['GET'])] + public function show(Initiative $initiative): Response + { + return $this->render('initiative/show.html.twig', [ + 'initiative' => $initiative, + ]); + } + + #[Route('/initiatives/{id}/edit', name: 'app_initiative_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])] + public function edit(Request $request, Initiative $initiative, EntityManagerInterface $entityManager): Response + { + $form = $this->createForm(InitiativeType::class, $initiative); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->removeEmptyMedia($initiative); + $entityManager->flush(); + + $this->addFlash('success', 'flash.initiative.updated'); + + return $this->redirectToRoute('app_initiative_show', ['id' => $initiative->getId()]); + } + + return $this->render('initiative/edit.html.twig', [ + 'form' => $form, + 'initiative' => $initiative, + ]); + } + + #[Route('/initiatives/{id}/delete', name: 'app_initiative_delete', requirements: ['id' => '\d+'], methods: ['POST'])] + public function delete(Request $request, Initiative $initiative, EntityManagerInterface $entityManager): Response + { + if ($this->isCsrfTokenValid('delete-initiative-'.$initiative->getId(), (string) $request->request->get('_token'))) { + $entityManager->remove($initiative); + $entityManager->flush(); + $this->addFlash('success', 'flash.initiative.deleted'); + } + + return $this->redirectToRoute('app_initiative_index'); + } + + /** + * Drop media rows the user added but left empty (no uploaded file). + */ + private function removeEmptyMedia(Initiative $initiative): void + { + foreach ($initiative->getImages() as $image) { + if (!$image->hasFile()) { + $initiative->removeImage($image); + } + } + + foreach ($initiative->getAttachments() as $attachment) { + if (!$attachment->hasFile()) { + $initiative->removeAttachment($attachment); + } + } + } +} diff --git a/src/Controller/LocaleController.php b/src/Controller/LocaleController.php new file mode 100644 index 0000000..26f3191 --- /dev/null +++ b/src/Controller/LocaleController.php @@ -0,0 +1,39 @@ + 'en|da'])] + public function switch(string $locale, Request $request): Response + { + // Persist the choice only when a session already exists, so an + // anonymous hit to this public route does not allocate one. The + // language switcher is only shown to authenticated users, who always + // carry a session. + if ($request->hasPreviousSession()) { + $request->getSession()->set('_locale', $locale); + } + + $return = (string) $request->query->get('return', ''); + + // Only follow local, relative return paths to avoid open redirects. + // Reject protocol-relative (//host) and backslash variants (/\host), + // which some browsers normalise to //host. + if ('' !== $return + && str_starts_with($return, '/') + && !str_starts_with($return, '//') + && !str_contains($return, '\\')) { + return $this->redirect($return); + } + + return $this->redirectToRoute('app_dashboard'); + } +} diff --git a/src/Controller/MediaController.php b/src/Controller/MediaController.php new file mode 100644 index 0000000..b095395 --- /dev/null +++ b/src/Controller/MediaController.php @@ -0,0 +1,37 @@ + '\d+'], methods: ['GET'])] + public function image(InitiativeImage $image): Response + { + return $this->downloadHandler->downloadObject($image, 'imageFile', InitiativeImage::class, $image->getOriginalName(), false); + } + + #[Route('/media/attachment/{id}', name: 'app_media_attachment', requirements: ['id' => '\d+'], methods: ['GET'])] + public function attachment(InitiativeAttachment $attachment): Response + { + return $this->downloadHandler->downloadObject($attachment, 'file', InitiativeAttachment::class, $attachment->getOriginalName() ?? 'attachment', true); + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000..38119f0 --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,39 @@ +getUser()) { + return $this->redirectToRoute('app_dashboard'); + } + + return $this->render('security/login.html.twig', [ + 'last_username' => $authenticationUtils->getLastUsername(), + 'error' => $authenticationUtils->getLastAuthenticationError(), + ]); + } + + /** + * Intercepted by the logout key on the firewall, so the body never runs. + * + * @codeCoverageIgnore + */ + #[Route('/logout', name: 'app_logout')] + #[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/AppFixtures.php b/src/DataFixtures/AppFixtures.php new file mode 100644 index 0000000..eddd42a --- /dev/null +++ b/src/DataFixtures/AppFixtures.php @@ -0,0 +1,185 @@ +setEmail('admin@example.com') + ->setName('Administrator') + ->setRoles(['ROLE_ADMIN']); + $admin->setPassword($this->hasher->hashPassword($admin, 'password')); + $manager->persist($admin); + + $editor = (new User()) + ->setEmail('editor@example.com') + ->setName('Redaktør') + ->setRoles(['ROLE_USER']); + $editor->setPassword($this->hasher->hashPassword($editor, 'password')); + $manager->persist($editor); + + $tags = $this->makeTerms($manager, self::TAGS, Vocabulary::Tag); + $stakeholders = $this->makeTerms($manager, self::STAKEHOLDERS, Vocabulary::Stakeholder); + $strategies = $this->makeTerms($manager, self::STRATEGIES, Vocabulary::Strategy); + + $contacts = []; + $firstNames = ['Anne', 'Mette', 'Lars', 'Søren', 'Camilla', 'Jens', 'Ida', 'Mads', 'Sofie', 'Peter', 'Louise', 'Thomas']; + $lastNames = ['Jensen', 'Nielsen', 'Hansen', 'Pedersen', 'Andersen', 'Christensen', 'Larsen', 'Sørensen']; + $departments = ['Borgmesterens Afdeling', 'Teknik og Miljø', 'Kultur og Borgerservice', 'Sociale Forhold og Beskæftigelse', 'Børn og Unge', 'Sundhed og Omsorg']; + for ($i = 0; $i < 14; ++$i) { + $name = $firstNames[array_rand($firstNames)].' '.$lastNames[array_rand($lastNames)]; + $contact = (new Contact()) + ->setName($name) + ->setEmail(strtolower(str_replace(' ', '.', $this->ascii($name))).'@aarhus.dk') + ->setPhone('+45 '.mt_rand(20, 99).' '.mt_rand(10, 99).' '.mt_rand(10, 99).' '.mt_rand(10, 99)) + ->setDepartment($departments[array_rand($departments)]); + $manager->persist($contact); + $contacts[] = $contact; + } + + $titles = [ + 'Grøn omstilling af kommunens bygninger', + 'Digital borgerservice 2.0', + 'Cykelstier i midtbyen', + 'Ungdomsråd og demokrati', + 'Klimatilpasning af Aarhus Å', + 'Smart City sensornetværk', + 'Fællesskaber i udsatte boligområder', + 'Bæredygtig madproduktion i institutioner', + 'Mobilitetshub ved banegården', + 'Sundhedshuse i lokalområderne', + 'Genbrug og cirkulær økonomi', + 'Tryghedsvandringer og byrum', + 'Læringsplatform for folkeskolen', + 'Erhvervsfremme for iværksættere', + 'Biodiversitet i parker og grønne områder', + 'Energirenovering af skoler', + 'Kunst i det offentlige rum', + 'Velfærdsteknologi i ældreplejen', + 'Deleøkonomi og samkørsel', + 'Inklusion på arbejdsmarkedet', + 'Vandkvalitet i havnebadet', + 'Frivillighed og medborgerskab', + 'Datadrevet byplanlægning', + 'Klimavenlig transport for medarbejdere', + ]; + + $statuses = Status::cases(); + $categories = Category::cases(); + $types = InitiativeType::cases(); + $anchorings = OrganizationalAnchoring::cases(); + $endorsers = EndorsementAuthor::cases(); + $fundings = Funding::cases(); + + foreach ($titles as $index => $title) { + $initiative = (new Initiative()) + ->setTitle($title) + ->setCategory($categories[array_rand($categories)]) + ->setInitiativeType($types[array_rand($types)]) + ->setStatus($statuses[array_rand($statuses)]) + ->setOrganizationalAnchoring($anchorings[array_rand($anchorings)]) + ->setDescription('Initiativet arbejder med '.mb_strtolower($title).' gennem en tværgående indsats med fokus på borgernes hverdag og kommunens strategiske mål.') + ->setEndorsement(0 === $index % 3 ? false : true) + ->setBudget(mt_rand(1, 40) * 50000) + ->setAuthor($firstNames[array_rand($firstNames)].' '.$lastNames[array_rand($lastNames)]); + + if (0 !== $index % 4) { + $initiative->setEndorsementAuthor($endorsers[array_rand($endorsers)]); + } + + $initiative->setFunding(\array_slice($this->shuffleCopy($fundings), 0, mt_rand(1, 3))); + + $start = new \DateTimeImmutable(sprintf('2025-%02d-01', mt_rand(1, 12))); + $initiative->setTimePeriodStart($start); + $initiative->setTimePeriodEnd($start->modify('+'.mt_rand(6, 36).' months')); + + foreach (\array_slice($this->shuffleCopy($tags), 0, mt_rand(1, 4)) as $term) { + $initiative->addTag($term); + } + foreach (\array_slice($this->shuffleCopy($stakeholders), 0, mt_rand(1, 3)) as $term) { + $initiative->addStakeholder($term); + } + foreach (\array_slice($this->shuffleCopy($strategies), 0, mt_rand(0, 2)) as $term) { + $initiative->addStrategy($term); + } + foreach (\array_slice($this->shuffleCopy($contacts), 0, mt_rand(1, 3)) as $contact) { + $initiative->addContact($contact); + } + + $initiative->setLinks(['https://www.aarhus.dk']); + + $manager->persist($initiative); + } + + $manager->flush(); + } + + /** + * @param string[] $names + * + * @return Term[] + */ + private function makeTerms(ObjectManager $manager, array $names, Vocabulary $vocabulary): array + { + $terms = []; + foreach ($names as $name) { + $term = (new Term($vocabulary))->setName($name); + $manager->persist($term); + $terms[] = $term; + } + + return $terms; + } + + /** + * @template T + * + * @param array $items + * + * @return array + */ + private function shuffleCopy(array $items): array + { + shuffle($items); + + return $items; + } + + private function ascii(string $value): string + { + return str_replace( + ['æ', 'ø', 'å', 'Æ', 'Ø', 'Å', ' '], + ['ae', 'oe', 'aa', 'ae', 'oe', 'aa', '.'], + $value, + ); + } +} diff --git a/src/Entity/.gitignore b/src/Entity/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Entity/Contact.php b/src/Entity/Contact.php new file mode 100644 index 0000000..7687382 --- /dev/null +++ b/src/Entity/Contact.php @@ -0,0 +1,114 @@ +createdAt = new \DateTimeImmutable(); + $this->updatedAt = new \DateTimeImmutable(); + } + + #[ORM\PreUpdate] + public function touch(): void + { + $this->updatedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + $this->email = $email; + + return $this; + } + + public function getPhone(): ?string + { + return $this->phone; + } + + public function setPhone(?string $phone): static + { + $this->phone = $phone; + + return $this; + } + + public function getDepartment(): ?string + { + return $this->department; + } + + public function setDepartment(?string $department): static + { + $this->department = $department; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function __toString(): string + { + return (string) $this->name; + } +} diff --git a/src/Entity/Initiative.php b/src/Entity/Initiative.php new file mode 100644 index 0000000..1c0cbd0 --- /dev/null +++ b/src/Entity/Initiative.php @@ -0,0 +1,521 @@ + */ + #[ORM\ManyToMany(targetEntity: Term::class, cascade: ['persist'])] + #[ORM\JoinTable(name: 'initiative_strategy')] + private Collection $strategies; + + #[ORM\Column(length: 32, nullable: true, enumType: InitiativeType::class)] + private ?InitiativeType $initiativeType = null; + + #[ORM\Column(length: 32, nullable: true, enumType: Status::class)] + private ?Status $status = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $statusAdditional = null; + + #[ORM\Column(length: 64, nullable: true, enumType: OrganizationalAnchoring::class)] + private ?OrganizationalAnchoring $organizationalAnchoring = null; + + #[ORM\Column] + private bool $endorsement = true; + + #[ORM\Column(length: 32, nullable: true, enumType: EndorsementAuthor::class)] + private ?EndorsementAuthor $endorsementAuthor = null; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Contact::class, cascade: ['persist'])] + #[ORM\JoinTable(name: 'initiative_contact')] + private Collection $contacts; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: InitiativeImage::class, mappedBy: 'initiative', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $images; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: InitiativeAttachment::class, mappedBy: 'initiative', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $attachments; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Term::class, cascade: ['persist'])] + #[ORM\JoinTable(name: 'initiative_stakeholder')] + private Collection $stakeholders; + + #[Assert\PositiveOrZero] + #[ORM\Column(nullable: true)] + private ?int $budget = null; + + /** + * Stored as the backing values of {@see Funding}; accessors expose enums. + * + * @var list + */ + #[ORM\Column] + private array $funding = []; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Term::class, cascade: ['persist'])] + #[ORM\JoinTable(name: 'initiative_tag')] + private Collection $tags; + + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $timePeriodStart = null; + + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $timePeriodEnd = null; + + /** @var list */ + #[ORM\Column] + private array $links = []; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $author = null; + + #[ORM\Column] + private \DateTimeImmutable $createdAt; + + #[ORM\Column] + private \DateTimeImmutable $updatedAt; + + public function __construct() + { + $this->strategies = new ArrayCollection(); + $this->contacts = new ArrayCollection(); + $this->stakeholders = new ArrayCollection(); + $this->tags = new ArrayCollection(); + $this->images = new ArrayCollection(); + $this->attachments = new ArrayCollection(); + $this->createdAt = new \DateTimeImmutable(); + $this->updatedAt = new \DateTimeImmutable(); + } + + #[ORM\PreUpdate] + public function touch(): void + { + $this->updatedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getCategory(): ?Category + { + return $this->category; + } + + public function setCategory(?Category $category): static + { + $this->category = $category; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + /** @return Collection */ + public function getStrategies(): Collection + { + return $this->strategies; + } + + public function addStrategy(Term $term): static + { + if (!$this->strategies->contains($term)) { + $this->strategies->add($term); + } + + return $this; + } + + public function removeStrategy(Term $term): static + { + $this->strategies->removeElement($term); + + return $this; + } + + /** @param iterable $terms */ + public function setStrategies(iterable $terms): static + { + $this->strategies->clear(); + foreach ($terms as $term) { + $this->addStrategy($term); + } + + return $this; + } + + public function getInitiativeType(): ?InitiativeType + { + return $this->initiativeType; + } + + public function setInitiativeType(?InitiativeType $initiativeType): static + { + $this->initiativeType = $initiativeType; + + return $this; + } + + public function getStatus(): ?Status + { + return $this->status; + } + + public function setStatus(?Status $status): static + { + $this->status = $status; + + return $this; + } + + public function getStatusAdditional(): ?string + { + return $this->statusAdditional; + } + + public function setStatusAdditional(?string $statusAdditional): static + { + $this->statusAdditional = $statusAdditional; + + return $this; + } + + public function getOrganizationalAnchoring(): ?OrganizationalAnchoring + { + return $this->organizationalAnchoring; + } + + public function setOrganizationalAnchoring(?OrganizationalAnchoring $organizationalAnchoring): static + { + $this->organizationalAnchoring = $organizationalAnchoring; + + return $this; + } + + public function isEndorsement(): bool + { + return $this->endorsement; + } + + public function setEndorsement(bool $endorsement): static + { + $this->endorsement = $endorsement; + + return $this; + } + + public function getEndorsementAuthor(): ?EndorsementAuthor + { + return $this->endorsementAuthor; + } + + public function setEndorsementAuthor(?EndorsementAuthor $endorsementAuthor): static + { + $this->endorsementAuthor = $endorsementAuthor; + + return $this; + } + + /** @return Collection */ + public function getContacts(): Collection + { + return $this->contacts; + } + + public function addContact(Contact $contact): static + { + if (!$this->contacts->contains($contact)) { + $this->contacts->add($contact); + } + + return $this; + } + + public function removeContact(Contact $contact): static + { + $this->contacts->removeElement($contact); + + return $this; + } + + /** @return Collection */ + public function getImages(): Collection + { + return $this->images; + } + + public function addImage(InitiativeImage $image): static + { + if (!$this->images->contains($image)) { + $this->images->add($image); + $image->setInitiative($this); + } + + return $this; + } + + public function removeImage(InitiativeImage $image): static + { + $this->images->removeElement($image); + + return $this; + } + + /** @return Collection */ + public function getAttachments(): Collection + { + return $this->attachments; + } + + public function addAttachment(InitiativeAttachment $attachment): static + { + if (!$this->attachments->contains($attachment)) { + $this->attachments->add($attachment); + $attachment->setInitiative($this); + } + + return $this; + } + + public function removeAttachment(InitiativeAttachment $attachment): static + { + $this->attachments->removeElement($attachment); + + return $this; + } + + /** @return Collection */ + public function getStakeholders(): Collection + { + return $this->stakeholders; + } + + public function addStakeholder(Term $term): static + { + if (!$this->stakeholders->contains($term)) { + $this->stakeholders->add($term); + } + + return $this; + } + + public function removeStakeholder(Term $term): static + { + $this->stakeholders->removeElement($term); + + return $this; + } + + /** @param iterable $terms */ + public function setStakeholders(iterable $terms): static + { + $this->stakeholders->clear(); + foreach ($terms as $term) { + $this->addStakeholder($term); + } + + return $this; + } + + public function getBudget(): ?int + { + return $this->budget; + } + + public function setBudget(?int $budget): static + { + $this->budget = $budget; + + return $this; + } + + /** @return Funding[] */ + public function getFunding(): array + { + return array_values(array_filter(array_map( + static fn (string $value): ?Funding => Funding::tryFrom($value), + $this->funding, + ))); + } + + /** @param Funding[] $funding */ + public function setFunding(array $funding): static + { + $this->funding = array_values(array_map( + static fn (Funding $item): string => $item->value, + $funding, + )); + + return $this; + } + + /** @return Collection */ + public function getTags(): Collection + { + return $this->tags; + } + + public function addTag(Term $term): static + { + if (!$this->tags->contains($term)) { + $this->tags->add($term); + } + + return $this; + } + + public function removeTag(Term $term): static + { + $this->tags->removeElement($term); + + return $this; + } + + /** @param iterable $terms */ + public function setTags(iterable $terms): static + { + $this->tags->clear(); + foreach ($terms as $term) { + $this->addTag($term); + } + + return $this; + } + + public function getTimePeriodStart(): ?\DateTimeImmutable + { + return $this->timePeriodStart; + } + + public function setTimePeriodStart(?\DateTimeImmutable $timePeriodStart): static + { + $this->timePeriodStart = $timePeriodStart; + + return $this; + } + + public function getTimePeriodEnd(): ?\DateTimeImmutable + { + return $this->timePeriodEnd; + } + + public function setTimePeriodEnd(?\DateTimeImmutable $timePeriodEnd): static + { + $this->timePeriodEnd = $timePeriodEnd; + + return $this; + } + + /** @return list */ + public function getLinks(): array + { + return $this->links; + } + + /** @param list $links */ + public function setLinks(array $links): static + { + $this->links = array_values(array_filter( + array_map(static fn (?string $link): string => trim((string) $link), $links), + // Keep only non-empty http(s) URLs; drop schemes like javascript: that enable stored XSS. + static function (string $link): bool { + if ('' === $link) { + return false; + } + $scheme = strtolower((string) parse_url($link, \PHP_URL_SCHEME)); + + return 'http' === $scheme || 'https' === $scheme; + }, + )); + + return $this; + } + + public function getAuthor(): ?string + { + return $this->author; + } + + public function setAuthor(?string $author): static + { + $this->author = $author; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updatedAt; + } + + public function __toString(): string + { + return (string) $this->title; + } +} diff --git a/src/Entity/InitiativeAttachment.php b/src/Entity/InitiativeAttachment.php new file mode 100644 index 0000000..2208b6a --- /dev/null +++ b/src/Entity/InitiativeAttachment.php @@ -0,0 +1,131 @@ +id; + } + + public function getInitiative(): ?Initiative + { + return $this->initiative; + } + + public function setInitiative(?Initiative $initiative): static + { + $this->initiative = $initiative; + + return $this; + } + + public function setFile(?File $file = null): void + { + $this->file = $file; + + if (null !== $file) { + $this->updatedAt = new \DateTimeImmutable(); + } + } + + public function getFile(): ?File + { + return $this->file; + } + + public function getFileName(): ?string + { + return $this->fileName; + } + + public function setFileName(?string $fileName): static + { + $this->fileName = $fileName; + + return $this; + } + + public function getOriginalName(): ?string + { + return $this->originalName; + } + + public function setOriginalName(?string $originalName): static + { + $this->originalName = $originalName; + + return $this; + } + + public function getMimeType(): ?string + { + return $this->mimeType; + } + + public function setMimeType(?string $mimeType): static + { + $this->mimeType = $mimeType; + + return $this; + } + + public function getSize(): ?int + { + return $this->size; + } + + public function setSize(?int $size): static + { + $this->size = $size; + + return $this; + } + + public function hasFile(): bool + { + return null !== $this->file || null !== $this->fileName; + } +} diff --git a/src/Entity/InitiativeImage.php b/src/Entity/InitiativeImage.php new file mode 100644 index 0000000..2da1e07 --- /dev/null +++ b/src/Entity/InitiativeImage.php @@ -0,0 +1,147 @@ +id; + } + + public function getInitiative(): ?Initiative + { + return $this->initiative; + } + + public function setInitiative(?Initiative $initiative): static + { + $this->initiative = $initiative; + + return $this; + } + + public function setImageFile(?File $imageFile = null): void + { + $this->imageFile = $imageFile; + + // Vich needs a mapped field to change so Doctrine persists the upload. + if (null !== $imageFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + } + + public function getImageFile(): ?File + { + return $this->imageFile; + } + + public function getImageName(): ?string + { + return $this->imageName; + } + + public function setImageName(?string $imageName): static + { + $this->imageName = $imageName; + + return $this; + } + + public function getOriginalName(): ?string + { + return $this->originalName; + } + + public function setOriginalName(?string $originalName): static + { + $this->originalName = $originalName; + + return $this; + } + + public function getMimeType(): ?string + { + return $this->mimeType; + } + + public function setMimeType(?string $mimeType): static + { + $this->mimeType = $mimeType; + + return $this; + } + + public function getSize(): ?int + { + return $this->size; + } + + public function setSize(?int $size): static + { + $this->size = $size; + + return $this; + } + + public function getAlt(): ?string + { + return $this->alt; + } + + public function setAlt(?string $alt): static + { + $this->alt = $alt; + + return $this; + } + + public function hasFile(): bool + { + return null !== $this->imageFile || null !== $this->imageName; + } +} diff --git a/src/Entity/Term.php b/src/Entity/Term.php new file mode 100644 index 0000000..43a09e5 --- /dev/null +++ b/src/Entity/Term.php @@ -0,0 +1,75 @@ +vocabulary = $vocabulary; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getVocabulary(): Vocabulary + { + return $this->vocabulary; + } + + public function setVocabulary(Vocabulary $vocabulary): static + { + $this->vocabulary = $vocabulary; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function __toString(): string + { + return (string) $this->name; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..977bad1 --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,118 @@ + */ + #[ORM\Column] + private array $roles = []; + + #[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; + } + + /** + * Display name, falling back to the e-mail when no name is set. This is a + * convenience accessor, not a pure getter: callers needing the raw stored + * value should not rely on it. + */ + public function getName(): string + { + return '' !== $this->name ? $this->name : (string) $this->email; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + /** + * A visual identifier that represents this user. + */ + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @return list + */ + public function getRoles(): array + { + $roles = $this->roles; + // Guarantee every user at least has ROLE_USER. + $roles[] = 'ROLE_USER'; + + return array_values(array_unique($roles)); + } + + /** + * @param list $roles + */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + public function eraseCredentials(): void + { + } +} diff --git a/src/Enum/Category.php b/src/Enum/Category.php new file mode 100644 index 0000000..010e3d8 --- /dev/null +++ b/src/Enum/Category.php @@ -0,0 +1,26 @@ +value; + } +} diff --git a/src/Enum/EndorsementAuthor.php b/src/Enum/EndorsementAuthor.php new file mode 100644 index 0000000..be90def --- /dev/null +++ b/src/Enum/EndorsementAuthor.php @@ -0,0 +1,22 @@ +value; + } +} diff --git a/src/Enum/Funding.php b/src/Enum/Funding.php new file mode 100644 index 0000000..8658236 --- /dev/null +++ b/src/Enum/Funding.php @@ -0,0 +1,19 @@ +value; + } +} diff --git a/src/Enum/InitiativeType.php b/src/Enum/InitiativeType.php new file mode 100644 index 0000000..28ef0da --- /dev/null +++ b/src/Enum/InitiativeType.php @@ -0,0 +1,23 @@ +value; + } +} diff --git a/src/Enum/OrganizationalAnchoring.php b/src/Enum/OrganizationalAnchoring.php new file mode 100644 index 0000000..6378514 --- /dev/null +++ b/src/Enum/OrganizationalAnchoring.php @@ -0,0 +1,24 @@ +value; + } +} diff --git a/src/Enum/Status.php b/src/Enum/Status.php new file mode 100644 index 0000000..a1aac34 --- /dev/null +++ b/src/Enum/Status.php @@ -0,0 +1,24 @@ +value; + } +} diff --git a/src/Enum/TranslatableEnum.php b/src/Enum/TranslatableEnum.php new file mode 100644 index 0000000..8908067 --- /dev/null +++ b/src/Enum/TranslatableEnum.php @@ -0,0 +1,17 @@ +value; + } +} diff --git a/src/EventListener/LocaleSubscriber.php b/src/EventListener/LocaleSubscriber.php new file mode 100644 index 0000000..7294a93 --- /dev/null +++ b/src/EventListener/LocaleSubscriber.php @@ -0,0 +1,40 @@ +getRequest(); + + if (!$request->hasPreviousSession()) { + return; + } + + $locale = $request->getSession()->get('_locale'); + $request->setLocale(\is_string($locale) ? $locale : $this->defaultLocale); + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => [['onKernelRequest', 20]], + ]; + } +} diff --git a/src/EventListener/SecurityHeadersSubscriber.php b/src/EventListener/SecurityHeadersSubscriber.php new file mode 100644 index 0000000..e6b9419 --- /dev/null +++ b/src/EventListener/SecurityHeadersSubscriber.php @@ -0,0 +1,49 @@ +isMainRequest()) { + return; + } + + $headers = $event->getResponse()->headers; + $headers->set('X-Content-Type-Options', 'nosniff'); + $headers->set('X-Frame-Options', 'DENY'); + $headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + if (!$this->debug) { + $headers->set('Content-Security-Policy', self::CSP); + } + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::RESPONSE => 'onKernelResponse', + ]; + } +} diff --git a/src/Form/ContactType.php b/src/Form/ContactType.php new file mode 100644 index 0000000..668f6d6 --- /dev/null +++ b/src/Form/ContactType.php @@ -0,0 +1,46 @@ + + */ +class ContactType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('name', TextType::class, [ + 'label' => 'contact.name', + ]) + ->add('email', EmailType::class, [ + 'label' => 'contact.email', + 'required' => false, + ]) + ->add('phone', TelType::class, [ + 'label' => 'contact.phone', + 'required' => false, + ]) + ->add('department', TextType::class, [ + 'label' => 'contact.department', + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Contact::class, + ]); + } +} diff --git a/src/Form/DataTransformer/TermsTextTransformer.php b/src/Form/DataTransformer/TermsTextTransformer.php new file mode 100644 index 0000000..b309d90 --- /dev/null +++ b/src/Form/DataTransformer/TermsTextTransformer.php @@ -0,0 +1,74 @@ + + */ +final readonly class TermsTextTransformer implements DataTransformerInterface +{ + public function __construct( + private TermRepository $termRepository, + private Vocabulary $vocabulary, + ) { + } + + public function transform(mixed $value): string + { + if (!is_iterable($value)) { + return ''; + } + + $names = []; + foreach ($value as $term) { + if ($term instanceof Term) { + $names[] = $term->getName(); + } + } + + return implode(', ', $names); + } + + /** + * @return Collection + */ + public function reverseTransform(mixed $value): Collection + { + $terms = new ArrayCollection(); + + if (!\is_string($value) || '' === trim($value)) { + return $terms; + } + + $seen = []; + foreach (explode(',', $value) as $name) { + $name = trim($name); + $key = mb_strtolower($name); + if ('' === $name || isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $terms->add($this->termRepository->findOrCreate($name, $this->vocabulary)); + } + + return $terms; + } +} diff --git a/src/Form/InitiativeAttachmentType.php b/src/Form/InitiativeAttachmentType.php new file mode 100644 index 0000000..a434b45 --- /dev/null +++ b/src/Form/InitiativeAttachmentType.php @@ -0,0 +1,55 @@ + + */ +class InitiativeAttachmentType extends AbstractType +{ + public function __construct( + #[Autowire('%env(INITIATIVE_ATTACHMENT_MAX_SIZE)%')] + private readonly string $maxFileSize, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('file', FileType::class, [ + 'label' => 'initiative.attachment_file', + 'required' => false, + 'attr' => ['accept' => '.pdf,.doc,.docx,.xls,.xlsx'], + 'constraints' => [ + new Assert\File( + maxSize: $this->maxFileSize, + mimeTypes: [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ], + mimeTypesMessage: 'initiative.attachment_invalid_type', + ), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => InitiativeAttachment::class, + ]); + } +} diff --git a/src/Form/InitiativeFilterType.php b/src/Form/InitiativeFilterType.php new file mode 100644 index 0000000..f03df09 --- /dev/null +++ b/src/Form/InitiativeFilterType.php @@ -0,0 +1,100 @@ + + */ +class InitiativeFilterType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $boolChoiceValue = static fn (?bool $value): string => null === $value ? '' : ($value ? '1' : '0'); + + $builder + ->add('q', SearchType::class, [ + 'label' => 'filter.search', + 'required' => false, + 'attr' => ['placeholder' => 'filter.search_placeholder'], + ]) + ->add('status', EnumType::class, [ + 'label' => 'initiative.status', + 'class' => Status::class, + 'required' => false, + 'placeholder' => 'filter.all', + 'choice_label' => static fn (Status $value): string => $value->labelKey(), + ]) + ->add('category', EnumType::class, [ + 'label' => 'initiative.category', + 'class' => Category::class, + 'required' => false, + 'placeholder' => 'filter.all', + 'choice_label' => static fn (Category $value): string => $value->labelKey(), + ]) + ->add('initiativeType', EnumType::class, [ + 'label' => 'initiative.initiative_type', + 'class' => InitiativeTypeEnum::class, + 'required' => false, + 'placeholder' => 'filter.all', + 'choice_label' => static fn (InitiativeTypeEnum $value): string => $value->labelKey(), + ]) + ->add('organizationalAnchoring', EnumType::class, [ + 'label' => 'initiative.organizational_anchoring', + 'class' => OrganizationalAnchoring::class, + 'required' => false, + 'placeholder' => 'filter.all', + 'choice_label' => static fn (OrganizationalAnchoring $value): string => $value->labelKey(), + ]) + ->add('endorsement', ChoiceType::class, [ + 'label' => 'initiative.endorsement', + 'required' => false, + 'placeholder' => 'filter.all', + 'choices' => ['filter.yes' => true, 'filter.no' => false], + 'choice_value' => $boolChoiceValue, + ]) + ->add('budgetMin', IntegerType::class, [ + 'label' => 'filter.budget_min', + 'required' => false, + 'attr' => ['min' => 0], + ]) + ->add('budgetMax', IntegerType::class, [ + 'label' => 'filter.budget_max', + 'required' => false, + 'attr' => ['min' => 0], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => InitiativeFilter::class, + 'method' => 'GET', + 'csrf_protection' => false, + 'required' => false, + // The list also carries sort/direction/page query params that are + // not form fields; ignore them instead of failing validation. + 'allow_extra_fields' => true, + ]); + } + + public function getBlockPrefix(): string + { + // Empty prefix keeps the query string clean (?status=…&q=…). + return ''; + } +} diff --git a/src/Form/InitiativeImageType.php b/src/Form/InitiativeImageType.php new file mode 100644 index 0000000..0dba605 --- /dev/null +++ b/src/Form/InitiativeImageType.php @@ -0,0 +1,50 @@ + + */ +class InitiativeImageType extends AbstractType +{ + public function __construct( + #[Autowire('%env(INITIATIVE_IMAGE_MAX_SIZE)%')] + private readonly string $maxImageSize, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('imageFile', FileType::class, [ + 'label' => 'initiative.image_file', + 'required' => false, + 'attr' => ['accept' => 'image/png,image/jpeg,image/gif'], + 'constraints' => [ + new Assert\Image(maxSize: $this->maxImageSize), + ], + ]) + ->add('alt', TextType::class, [ + 'label' => 'initiative.image_alt', + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => InitiativeImage::class, + ]); + } +} diff --git a/src/Form/InitiativeType.php b/src/Form/InitiativeType.php new file mode 100644 index 0000000..ba04965 --- /dev/null +++ b/src/Form/InitiativeType.php @@ -0,0 +1,213 @@ + + */ +class InitiativeType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('title', TextType::class, [ + 'label' => 'initiative.title', + ]) + ->add('category', EnumType::class, [ + 'label' => 'initiative.category', + 'class' => Category::class, + 'required' => false, + 'placeholder' => 'form.choose', + 'choice_label' => static fn (Category $value): string => $value->labelKey(), + ]) + ->add('description', TextareaType::class, [ + 'label' => 'initiative.description', + 'required' => false, + 'attr' => ['rows' => 4], + ]) + ->add('strategies', TermsTextType::class, [ + 'label' => 'initiative.strategies', + 'vocabulary' => Vocabulary::Strategy, + 'required' => false, + 'help' => 'initiative.terms_help', + ]) + ->add('initiativeType', EnumType::class, [ + 'label' => 'initiative.initiative_type', + 'class' => InitiativeTypeEnum::class, + 'required' => false, + 'placeholder' => 'form.choose', + 'choice_label' => static fn (InitiativeTypeEnum $value): string => $value->labelKey(), + ]) + ->add('status', EnumType::class, [ + 'label' => 'initiative.status', + 'class' => Status::class, + 'required' => false, + 'placeholder' => 'form.choose', + 'choice_label' => static fn (Status $value): string => $value->labelKey(), + ]) + ->add('statusAdditional', TextareaType::class, [ + 'label' => 'initiative.status_additional', + 'required' => false, + 'attr' => ['rows' => 3], + ]) + ->add('organizationalAnchoring', EnumType::class, [ + 'label' => 'initiative.organizational_anchoring', + 'class' => OrganizationalAnchoring::class, + 'required' => false, + 'placeholder' => 'form.choose', + 'choice_label' => static fn (OrganizationalAnchoring $value): string => $value->labelKey(), + ]) + ->add('endorsement', CheckboxType::class, [ + 'label' => 'initiative.endorsement', + 'required' => false, + ]) + ->add('endorsementAuthor', EnumType::class, [ + 'label' => 'initiative.endorsement_author', + 'class' => EndorsementAuthor::class, + 'required' => false, + 'placeholder' => 'form.choose', + 'choice_label' => static fn (EndorsementAuthor $value): string => $value->labelKey(), + ]) + ->add('budget', IntegerType::class, [ + 'label' => 'initiative.budget', + 'required' => false, + 'attr' => ['min' => 0], + ]) + ->add('funding', EnumType::class, [ + 'label' => 'initiative.funding', + 'class' => Funding::class, + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'choice_label' => static fn (Funding $value): string => $value->labelKey(), + ]) + ->add('stakeholders', TermsTextType::class, [ + 'label' => 'initiative.stakeholders', + 'vocabulary' => Vocabulary::Stakeholder, + 'required' => false, + 'help' => 'initiative.terms_help', + ]) + ->add('tags', TermsTextType::class, [ + 'label' => 'initiative.tags', + 'vocabulary' => Vocabulary::Tag, + 'required' => false, + 'help' => 'initiative.terms_help', + ]) + ->add('timePeriodStart', DateType::class, [ + 'label' => 'initiative.time_period_start', + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'required' => false, + ]) + ->add('timePeriodEnd', DateType::class, [ + 'label' => 'initiative.time_period_end', + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'required' => false, + ]) + ->add('links', CollectionType::class, [ + 'label' => 'initiative.links', + 'entry_type' => UrlType::class, + 'entry_options' => [ + 'required' => false, + 'default_protocol' => 'https', + 'label' => false, + // Reject non-http(s) URLs (e.g. javascript:) to prevent stored XSS. + 'constraints' => [new Assert\Url(protocols: ['http', 'https'])], + ], + 'allow_add' => true, + 'allow_delete' => true, + 'delete_empty' => true, + 'by_reference' => false, + 'required' => false, + 'prototype' => true, + ]) + ->add('contacts', EntityType::class, [ + 'label' => 'initiative.contacts', + 'class' => Contact::class, + 'choice_label' => 'name', + 'multiple' => true, + 'required' => false, + 'by_reference' => false, + 'attr' => ['data-contact-select' => true], + ]) + ->add('newContacts', CollectionType::class, [ + 'label' => 'initiative.new_contacts', + 'entry_type' => ContactType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'delete_empty' => static fn (?Contact $contact): bool => null === $contact || null === $contact->getName() || '' === trim((string) $contact->getName()), + 'by_reference' => false, + 'required' => false, + 'prototype' => true, + 'mapped' => false, + ]) + ->add('images', CollectionType::class, [ + 'label' => 'initiative.images', + 'entry_type' => InitiativeImageType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'required' => false, + 'prototype' => true, + ]) + ->add('attachments', CollectionType::class, [ + 'label' => 'initiative.attachments', + 'entry_type' => InitiativeAttachmentType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'required' => false, + 'prototype' => true, + ]) + ->add('author', TextType::class, [ + 'label' => 'initiative.author', + 'required' => false, + ]); + + // Existing contacts bind directly through the select; brand-new ones are + // built in the unmapped "newContacts" collection and merged in here. + $builder->addEventListener(FormEvents::POST_SUBMIT, static function (FormEvent $event): void { + $initiative = $event->getData(); + if ($initiative instanceof Initiative) { + foreach ($event->getForm()->get('newContacts')->getData() as $contact) { + $initiative->addContact($contact); + } + } + }); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Initiative::class, + ]); + } +} diff --git a/src/Form/TermsTextType.php b/src/Form/TermsTextType.php new file mode 100644 index 0000000..5f70b77 --- /dev/null +++ b/src/Form/TermsTextType.php @@ -0,0 +1,45 @@ + + */ +final class TermsTextType extends AbstractType +{ + public function __construct(private readonly TermRepository $termRepository) + { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addModelTransformer(new TermsTextTransformer($this->termRepository, $options['vocabulary'])); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired('vocabulary'); + $resolver->setAllowedTypes('vocabulary', Vocabulary::class); + $resolver->setDefaults([ + 'invalid_message' => 'form.terms.invalid', + ]); + } + + public function getParent(): string + { + return TextType::class; + } +} diff --git a/src/Form/UserType.php b/src/Form/UserType.php new file mode 100644 index 0000000..258cb90 --- /dev/null +++ b/src/Form/UserType.php @@ -0,0 +1,76 @@ + + */ +class UserType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $passwordConstraints = []; + if ($options['require_password']) { + $passwordConstraints = [ + new NotBlank(message: 'user.password_required'), + new Length(min: 8, minMessage: 'user.password_too_short'), + ]; + } + + $builder + ->add('email', EmailType::class, [ + 'label' => 'user.email', + ]) + ->add('name', TextType::class, [ + 'label' => 'user.name', + 'required' => false, + ]) + // Mapped onto User::$roles, so this field can grant ROLE_ADMIN. It + // relies on UserController being gated by #[IsGranted('ROLE_ADMIN')]; + // do not reuse this form for self-service profile editing without + // removing this field. The Choice constraint limits submissions to + // the known roles so a tampered request cannot inject arbitrary ones. + ->add('roles', ChoiceType::class, [ + 'label' => 'user.roles', + 'choices' => [ + 'role.user' => 'ROLE_USER', + 'role.admin' => 'ROLE_ADMIN', + ], + 'multiple' => true, + 'expanded' => true, + 'constraints' => [ + new Choice(choices: ['ROLE_USER', 'ROLE_ADMIN'], multiple: true), + ], + ]) + ->add('plainPassword', PasswordType::class, [ + 'label' => 'user.password', + 'mapped' => false, + 'required' => $options['require_password'], + 'constraints' => $passwordConstraints, + 'help' => 'user.password_help', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => User::class, + 'require_password' => true, + ]); + $resolver->setAllowedTypes('require_password', 'bool'); + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000..a00950f --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,19 @@ + An array of allowed values for APP_ENV + */ + private function getAllowedEnvs(): array + { + return ['prod', 'dev', 'test']; + } +} diff --git a/src/Model/InitiativeFilter.php b/src/Model/InitiativeFilter.php new file mode 100644 index 0000000..238c2cf --- /dev/null +++ b/src/Model/InitiativeFilter.php @@ -0,0 +1,37 @@ + + */ +class ContactRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Contact::class); + } + + /** + * @return Contact[] + */ + public function findAllOrdered(): array + { + return $this->createQueryBuilder('c') + ->orderBy('c.name', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php new file mode 100644 index 0000000..d8e1ac4 --- /dev/null +++ b/src/Repository/InitiativeRepository.php @@ -0,0 +1,147 @@ + + */ +class InitiativeRepository extends ServiceEntityRepository +{ + public const SORTABLE = ['title', 'budget', 'createdAt', 'timePeriodStart']; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Initiative::class); + } + + public function search(InitiativeFilter $filter): QueryBuilder + { + $qb = $this->createQueryBuilder('i'); + + if (null !== $filter->q && '' !== trim($filter->q)) { + // Escape LIKE wildcards so a user-typed % or _ is matched literally + // instead of acting as a wildcard. Backslash is MariaDB's default + // LIKE escape character. + $term = addcslashes(mb_strtolower(trim($filter->q)), '%_\\'); + $qb->andWhere('LOWER(i.title) LIKE :q OR LOWER(i.description) LIKE :q OR LOWER(i.author) LIKE :q OR LOWER(i.statusAdditional) LIKE :q') + ->setParameter('q', '%'.$term.'%'); + } + + if (null !== $filter->status) { + $qb->andWhere('i.status = :status')->setParameter('status', $filter->status->value); + } + + if (null !== $filter->category) { + $qb->andWhere('i.category = :category')->setParameter('category', $filter->category->value); + } + + if (null !== $filter->initiativeType) { + $qb->andWhere('i.initiativeType = :initiativeType')->setParameter('initiativeType', $filter->initiativeType->value); + } + + if (null !== $filter->organizationalAnchoring) { + $qb->andWhere('i.organizationalAnchoring = :anchoring')->setParameter('anchoring', $filter->organizationalAnchoring->value); + } + + if (null !== $filter->endorsement) { + $qb->andWhere('i.endorsement = :endorsement')->setParameter('endorsement', $filter->endorsement); + } + + if (null !== $filter->budgetMin) { + $qb->andWhere('i.budget >= :budgetMin')->setParameter('budgetMin', $filter->budgetMin); + } + + if (null !== $filter->budgetMax) { + $qb->andWhere('i.budget <= :budgetMax')->setParameter('budgetMax', $filter->budgetMax); + } + + $sort = \in_array($filter->sort, self::SORTABLE, true) ? $filter->sort : 'createdAt'; + $direction = 'ASC' === strtoupper($filter->direction) ? 'ASC' : 'DESC'; + + return $qb->orderBy('i.'.$sort, $direction); + } + + /** + * Returns the filtered initiatives with every to-many collection primed, so + * a CSV export can read them without firing a query per row (N+1). Each + * association is loaded in its own query; fetch-joining them all at once + * would multiply rows (a cartesian product) instead of cutting queries. + * + * @return Initiative[] + */ + public function findForExport(InitiativeFilter $filter): array + { + /** @var Initiative[] $initiatives */ + $initiatives = $this->search($filter)->getQuery()->getResult(); + + if ([] === $initiatives) { + return []; + } + + $ids = array_map(static fn (Initiative $initiative): int => (int) $initiative->getId(), $initiatives); + + foreach (['strategies', 'stakeholders', 'tags', 'contacts'] as $association) { + $this->createQueryBuilder('i') + ->addSelect('rel') + ->leftJoin('i.'.$association, 'rel') + ->andWhere('i.id IN (:ids)') + ->setParameter('ids', $ids) + ->getQuery() + ->getResult(); + } + + return $initiatives; + } + + public function countAll(): int + { + return (int) $this->createQueryBuilder('i') + ->select('COUNT(i.id)') + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * @return array count keyed by status value (skips initiatives without a status) + */ + public function countByStatus(): array + { + $rows = $this->createQueryBuilder('i') + ->select('i.status AS status, COUNT(i.id) AS cnt') + ->groupBy('i.status') + ->getQuery() + ->getScalarResult(); + + $counts = []; + foreach ($rows as $row) { + // getScalarResult() returns the raw column value, so $row['status'] + // is the enum's backing string (or null), never a Status instance. + if (null === $row['status']) { + continue; + } + $counts[(string) $row['status']] = (int) $row['cnt']; + } + + return $counts; + } + + /** + * @return Initiative[] + */ + public function findRecent(int $limit = 5): array + { + return $this->createQueryBuilder('i') + ->orderBy('i.createdAt', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Repository/TermRepository.php b/src/Repository/TermRepository.php new file mode 100644 index 0000000..f20c9d8 --- /dev/null +++ b/src/Repository/TermRepository.php @@ -0,0 +1,61 @@ + + */ +class TermRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Term::class); + } + + /** + * @return Term[] + */ + public function findByVocabulary(Vocabulary $vocabulary): array + { + return $this->createQueryBuilder('t') + ->andWhere('t.vocabulary = :vocabulary') + ->setParameter('vocabulary', $vocabulary->value) + ->orderBy('t.name', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Return an existing term (case-insensitive) or a new, unflushed one. + * Supports the free-tagging vocabularies where terms are created on the fly. + */ + public function findOrCreate(string $name, Vocabulary $vocabulary): Term + { + $name = trim($name); + + $existing = $this->createQueryBuilder('t') + ->andWhere('LOWER(t.name) = :name') + ->andWhere('t.vocabulary = :vocabulary') + ->setParameter('name', mb_strtolower($name)) + ->setParameter('vocabulary', $vocabulary->value) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + if ($existing instanceof Term) { + return $existing; + } + + $term = (new Term($vocabulary))->setName($name); + $this->getEntityManager()->persist($term); + + return $term; + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..37a4368 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,34 @@ + + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + 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/Service/PaginationResult.php b/src/Service/PaginationResult.php new file mode 100644 index 0000000..5c33116 --- /dev/null +++ b/src/Service/PaginationResult.php @@ -0,0 +1,43 @@ + $items + */ + public function __construct( + public array $items, + public int $page, + public int $pages, + public int $total, + public int $perPage, + ) { + } + + public function hasPrevious(): bool + { + return $this->page > 1; + } + + public function hasNext(): bool + { + return $this->page < $this->pages; + } + + public function firstResult(): int + { + return 0 === $this->total ? 0 : (($this->page - 1) * $this->perPage) + 1; + } + + public function lastResult(): int + { + return min($this->page * $this->perPage, $this->total); + } +} diff --git a/src/Service/Paginator.php b/src/Service/Paginator.php new file mode 100644 index 0000000..42cc227 --- /dev/null +++ b/src/Service/Paginator.php @@ -0,0 +1,41 @@ + + */ + public function paginate(QueryBuilder $queryBuilder, int $page, int $perPage = self::PER_PAGE): PaginationResult + { + $query = $queryBuilder->getQuery(); + $paginator = new DoctrinePaginator($query, fetchJoinCollection: true); + + // Count first so the requested page can be clamped to the valid range + // *before* the offset query runs — otherwise ?page=999 issues a query + // with a huge offset and returns an empty page. + $total = \count($paginator); + $pages = (int) max(1, ceil($total / $perPage)); + $page = min(max(1, $page), $pages); + + $query + ->setFirstResult(($page - 1) * $perPage) + ->setMaxResults($perPage); + + return new PaginationResult( + items: array_values(iterator_to_array($paginator)), + page: $page, + pages: $pages, + total: $total, + perPage: $perPage, + ); + } +} diff --git a/src/Story/AppStory.php b/src/Story/AppStory.php new file mode 100644 index 0000000..c7bdfbb --- /dev/null +++ b/src/Story/AppStory.php @@ -0,0 +1,17 @@ + + +
+ {% block admin_content %}{% endblock %} +
+ +{% endblock %} diff --git a/templates/admin/users/edit.html.twig b/templates/admin/users/edit.html.twig new file mode 100644 index 0000000..5d8d3c5 --- /dev/null +++ b/templates/admin/users/edit.html.twig @@ -0,0 +1,31 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'admin.users.edit'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + + {{ form_start(form, {attr: {class: 'form'}}) }} +
+ {{ form_errors(form) }} + {{ form_row(form.email) }} + {{ form_row(form.name) }} + {{ form_row(form.roles) }} + {{ form_row(form.plainPassword) }} +
+
+ + {{ 'action.cancel'|trans }} +
+ {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/users/index.html.twig b/templates/admin/users/index.html.twig new file mode 100644 index 0000000..53fb1da --- /dev/null +++ b/templates/admin/users/index.html.twig @@ -0,0 +1,54 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'admin.users.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} + + +
+
+ + + + + + + + + + + {% for user in users %} + + + + + + + {% endfor %} + +
{{ 'user.email'|trans }}{{ 'user.name'|trans }}{{ 'user.roles'|trans }}
{{ user.email }}{{ user.name }} +
+ {% for role in user.roles %}{{ role }}{% endfor %} +
+
+
+ {{ 'action.edit'|trans }} + {% if user.id != app.user.id %} +
+ + +
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/templates/admin/users/new.html.twig b/templates/admin/users/new.html.twig new file mode 100644 index 0000000..ad6e68a --- /dev/null +++ b/templates/admin/users/new.html.twig @@ -0,0 +1,21 @@ +{% extends 'admin/base.html.twig' %} + +{% block title %}{{ 'admin.users.new'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block admin_content %} +

{{ 'admin.users.new'|trans }}

+ + {{ form_start(form, {attr: {class: 'form'}}) }} +
+ {{ form_errors(form) }} + {{ form_row(form.email) }} + {{ form_row(form.name) }} + {{ form_row(form.roles) }} + {{ form_row(form.plainPassword) }} +
+
+ + {{ 'action.cancel'|trans }} +
+ {{ form_end(form) }} +{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig new file mode 100644 index 0000000..ba236b5 --- /dev/null +++ b/templates/base.html.twig @@ -0,0 +1,72 @@ + + + + + + {% block title %}{{ 'app.name'|trans }}{% endblock %} + + {% block stylesheets %}{% endblock %} + {% block javascripts %} + {% block importmap %}{{ importmap('app') }}{% endblock %} + {% endblock %} + + + {% block nav %} + {% if app.user %} + {% set route = app.request.attributes.get('_route') %} + + {% endif %} + {% endblock %} + + {% block body %}{% endblock %} + + {% block flashes %} + {% set all_flashes = app.flashes %} + {% if all_flashes|length > 0 %} +
+ {% for type, messages in all_flashes %} + {% for message in messages %} +
{{ message|trans }}
+ {% endfor %} + {% endfor %} +
+ {% endif %} + {% endblock %} + + diff --git a/templates/contact/_form.html.twig b/templates/contact/_form.html.twig new file mode 100644 index 0000000..43211b0 --- /dev/null +++ b/templates/contact/_form.html.twig @@ -0,0 +1,15 @@ +{{ form_start(form, {attr: {class: 'form'}}) }} +
+ {{ form_errors(form) }} + {{ form_row(form.name) }} +
+ {{ form_row(form.email) }} + {{ form_row(form.phone) }} +
+ {{ form_row(form.department) }} +
+
+ + {{ 'action.cancel'|trans }} +
+{{ form_end(form) }} diff --git a/templates/contact/edit.html.twig b/templates/contact/edit.html.twig new file mode 100644 index 0000000..df60803 --- /dev/null +++ b/templates/contact/edit.html.twig @@ -0,0 +1,21 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'contact.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block body %} +
+ + {{ include('contact/_form.html.twig', {button_label: 'action.save'}) }} +
+{% endblock %} diff --git a/templates/contact/index.html.twig b/templates/contact/index.html.twig new file mode 100644 index 0000000..34eb66f --- /dev/null +++ b/templates/contact/index.html.twig @@ -0,0 +1,59 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'contact.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block body %} +
+ + +
+ {% if contacts is empty %} +
+
{{ 'contact.empty.title'|trans }}
+

{{ 'contact.empty.hint'|trans }}

+
+ {% else %} +
+ + + + + + + + + + + + {% for contact in contacts %} + + + + + + + + {% endfor %} + +
{{ 'contact.name'|trans }}{{ 'contact.department'|trans }}{{ 'contact.email'|trans }}{{ 'contact.phone'|trans }}
{{ contact.name }}{{ contact.department ?: '—' }}{% if contact.email %}{{ contact.email }}{% else %}—{% endif %}{{ contact.phone ?: '—' }} +
+ {{ 'action.edit'|trans }} +
+ + +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/contact/new.html.twig b/templates/contact/new.html.twig new file mode 100644 index 0000000..dfbf3dc --- /dev/null +++ b/templates/contact/new.html.twig @@ -0,0 +1,15 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'contact.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block body %} +
+ + {{ include('contact/_form.html.twig', {button_label: 'action.create'}) }} +
+{% endblock %} diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig new file mode 100644 index 0000000..dd732a4 --- /dev/null +++ b/templates/dashboard/index.html.twig @@ -0,0 +1,85 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'dashboard.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block body %} +
+ + +
+
+
{{ 'dashboard.total'|trans }}
+
{{ total }}
+
+
+
{{ 'nav.contacts'|trans }}
+
{{ contactCount }}
+
+
+ +
+
+
+ {{ 'dashboard.recent'|trans }} + {{ 'action.view'|trans }} +
+ {% if recent is empty %} +
+
{{ 'dashboard.empty'|trans }}
+ {{ 'action.new'|trans }} +
+ {% else %} +
+ {% for initiative in recent %} +
+
+ {{ initiative.title }} +
+ {%- if initiative.organizationalAnchoring %}{{ initiative.organizationalAnchoring.labelKey|trans }} · {% endif -%} + {{ initiative.createdAt|date('d.m.Y') }} +
+
+
+ {% if initiative.status %}{{ initiative.status.labelKey|trans }}{% endif %} +
+
+ {% endfor %} +
+ {% endif %} +
+ +
+
+ {{ 'dashboard.by_status'|trans }} +
+
+ {% set maxCount = 1 %} + {% for status in statuses %} + {% set maxCount = max(maxCount, byStatus[status.value]|default(0)) %} + {% endfor %} + {% for status in statuses %} + {% set count = byStatus[status.value]|default(0) %} +
+
+ {{ status.labelKey|trans }} + {{ count }} +
+
+
+
+
+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/templates/form/fields.html.twig b/templates/form/fields.html.twig new file mode 100644 index 0000000..7585252 --- /dev/null +++ b/templates/form/fields.html.twig @@ -0,0 +1,47 @@ +{% use 'form_div_layout.html.twig' %} + +{% block form_row %} + {% set row_class = 'form-row' %} +
+ {{ form_label(form) }} + {{ form_widget(form) }} + {{ form_errors(form) }} + {%- if help is not empty -%} +
{{ help|trans(help_translation_parameters, translation_domain) }}
+ {%- endif -%} +
+{% endblock %} + +{% block checkbox_row %} +
+
+ {{ form_widget(form) }} + {{ form_label(form) }} +
+ {{ form_errors(form) }} + {%- if help is not empty -%} +
{{ help|trans(help_translation_parameters, translation_domain) }}
+ {%- endif -%} +
+{% endblock %} + +{% block choice_widget_expanded %} +
+ {%- for child in form -%} + + {%- endfor -%} +
+{% endblock %} + +{% block form_errors %} + {%- if errors|length > 0 -%} +
    + {%- for error in errors -%} +
  • {{ error.message }}
  • + {%- endfor -%} +
+ {%- endif -%} +{% endblock %} diff --git a/templates/initiative/_form.html.twig b/templates/initiative/_form.html.twig new file mode 100644 index 0000000..f482e69 --- /dev/null +++ b/templates/initiative/_form.html.twig @@ -0,0 +1,133 @@ +{{ form_start(form, {attr: {class: 'form'}}) }} + {{ form_errors(form) }} + +
+
{{ 'initiative.section.basics'|trans }}
+ {{ form_row(form.title) }} +
+ {{ form_row(form.category) }} + {{ form_row(form.initiativeType) }} +
+ {{ form_row(form.description) }} +
+ +
+
{{ 'initiative.section.classification'|trans }}
+
+ {{ form_row(form.status) }} + {{ form_row(form.organizationalAnchoring) }} +
+ {{ form_row(form.statusAdditional) }} + {{ form_row(form.strategies) }} + {{ form_row(form.tags) }} +
+ +
+
{{ 'initiative.section.endorsement'|trans }}
+ {{ form_row(form.endorsement) }} + {{ form_row(form.endorsementAuthor) }} +
+ +
+
{{ 'initiative.section.economy'|trans }}
+
+ {{ form_row(form.budget) }} + {{ form_row(form.timePeriodStart) }} + {{ form_row(form.timePeriodEnd) }} +
+ {{ form_row(form.funding) }} + +
+ +
+
+ {% for link in form.links %} +
+ {{ form_widget(link) }} +
+ {% endfor %} +
+ +
+
+
+ +
+
{{ 'initiative.section.relations'|trans }}
+ {{ form_row(form.stakeholders) }} + + {{ form_row(form.contacts) }} + +
+ +
+
+ {% for contact in form.newContacts %} +
+
+ {{ form_widget(contact) }} +
+
+ {% endfor %} +
+ +
+
+
+ +
+
{{ 'initiative.section.media'|trans }}
+ +
+ +
+
+ {% for image in form.images %} +
+
+ {% if image.vars.value and image.vars.value.id and image.vars.value.imageName %} + {{ image.vars.value.originalName ?: 'image' }} + {% endif %} + {{ form_widget(image) }} +
+
+ {% endfor %} +
+ +
+
+ +
+ +
+
+ {% for attachment in form.attachments %} +
+
+ {% if attachment.vars.value and attachment.vars.value.id and attachment.vars.value.fileName %} + {{ attachment.vars.value.originalName ?: 'file' }} + {% endif %} + {{ form_widget(attachment) }} +
+
+ {% endfor %} +
+ +
+
+
+ +
+
{{ 'initiative.section.meta'|trans }}
+ {{ form_row(form.author) }} +
+ +
+ + {{ 'action.cancel'|trans }} +
+{{ form_end(form) }} diff --git a/templates/initiative/edit.html.twig b/templates/initiative/edit.html.twig new file mode 100644 index 0000000..8f75aa7 --- /dev/null +++ b/templates/initiative/edit.html.twig @@ -0,0 +1,24 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'initiative.edit.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block body %} +
+ + {{ include('initiative/_form.html.twig', {button_label: 'action.save'}) }} +
+{% endblock %} diff --git a/templates/initiative/index.html.twig b/templates/initiative/index.html.twig new file mode 100644 index 0000000..4252d26 --- /dev/null +++ b/templates/initiative/index.html.twig @@ -0,0 +1,119 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'initiative.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% macro sortlink(field, label, sort, direction) %} + {% set isActive = sort == field %} + {% set newDir = (isActive and direction == 'ASC') ? 'DESC' : 'ASC' %} + + {{ label|trans }}{% if isActive %} {{ direction == 'ASC' ? '▲' : '▼' }}{% endif %} + +{% endmacro %} + +{% block body %} + {% import _self as h %} +
+ + + {{ form_start(form, {attr: {class: 'filters'}}) }} + +
+ {{ form_row(form.status) }} + {{ form_row(form.category) }} + {{ form_row(form.initiativeType) }} + {{ form_row(form.organizationalAnchoring) }} + {{ form_row(form.endorsement) }} + {{ form_row(form.budgetMin) }} + {{ form_row(form.budgetMax) }} +
+
+ + {{ 'action.reset'|trans }} +
+ {{ form_end(form) }} + +
+ {% if pagination.items is empty %} +
+
{{ 'initiative.empty.title'|trans }}
+

{{ 'initiative.empty.hint'|trans }}

+
+ {% else %} +
+ + + + + + + + + + + + + + {% for initiative in pagination.items %} + + + + + + + + + + {% endfor %} + +
{{ h.sortlink('title', 'initiative.title', sort, direction) }}{{ 'initiative.status'|trans }}{{ 'initiative.initiative_type'|trans }}{{ 'initiative.organizational_anchoring'|trans }}{{ h.sortlink('budget', 'initiative.budget', sort, direction) }}{{ h.sortlink('timePeriodStart', 'initiative.time_period', sort, direction) }}
+ {{ initiative.title }} + + {% if initiative.status %} + {{ initiative.status.labelKey|trans }} + {% else %} + + {% endif %} + {{ initiative.initiativeType ? initiative.initiativeType.labelKey|trans : '—' }}{{ initiative.organizationalAnchoring ? initiative.organizationalAnchoring.labelKey|trans : '—' }}{{ initiative.budget is not null ? initiative.budget|number_format(0, ',', '.') : '—' }} + {% if initiative.timePeriodStart %} + {{ initiative.timePeriodStart|date('d.m.Y') }}{% if initiative.timePeriodEnd %} – {{ initiative.timePeriodEnd|date('d.m.Y') }}{% endif %} + {% else %}—{% endif %} + + +
+
+ + + {% endif %} +
+
+{% endblock %} diff --git a/templates/initiative/new.html.twig b/templates/initiative/new.html.twig new file mode 100644 index 0000000..ce9ddb9 --- /dev/null +++ b/templates/initiative/new.html.twig @@ -0,0 +1,15 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'initiative.new.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block body %} +
+ + {{ include('initiative/_form.html.twig', {button_label: 'action.create'}) }} +
+{% endblock %} diff --git a/templates/initiative/show.html.twig b/templates/initiative/show.html.twig new file mode 100644 index 0000000..6ecf55f --- /dev/null +++ b/templates/initiative/show.html.twig @@ -0,0 +1,147 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ initiative.title }} · {{ 'app.name'|trans }}{% endblock %} + +{% macro term_list(terms) %} + {% if terms|length > 0 %} +
+ {% for term in terms %}{{ term.name }}{% endfor %} +
+ {% else %}{% endif %} +{% endmacro %} + +{% block body %} + {% import _self as h %} +
+ + +
+
+
+
{{ 'initiative.show.details'|trans }}
+
+ {% if initiative.description %}

{{ initiative.description }}

{% else %}

{% endif %} + {% if initiative.statusAdditional %} +

{{ 'initiative.status_additional'|trans }}

+

{{ initiative.statusAdditional }}

+ {% endif %} + {% if initiative.links|length > 0 %} +

{{ 'initiative.links'|trans }}

+ + {% endif %} +
+
+ +
+
{{ 'initiative.show.contacts'|trans }}
+
+ {% if initiative.contacts|length > 0 %} + {% for contact in initiative.contacts %} +
+
{{ contact.name }}
+
+ {%- if contact.department %}{{ contact.department }}{% endif -%} + {%- if contact.email %} · {{ contact.email }}{% endif -%} + {%- if contact.phone %} · {{ contact.phone }}{% endif -%} +
+
+ {% endfor %} + {% else %} +

{{ 'initiative.show.no_contacts'|trans }}

+ {% endif %} +
+
+ + {% if initiative.images|length > 0 or initiative.attachments|length > 0 %} +
+
{{ 'initiative.show.media'|trans }}
+
+ {% if initiative.images|length > 0 %} +
+ {% for image in initiative.images %} + + {{ image.alt }} + + {% endfor %} +
+ {% endif %} + {% if initiative.attachments|length > 0 %} + + {% endif %} +
+
+ {% endif %} +
+ +
+
{{ 'initiative.show.classification'|trans }}
+
+
+
{{ 'initiative.category'|trans }}
+
{{ initiative.category ? initiative.category.labelKey|trans : '—' }}
+ +
{{ 'initiative.initiative_type'|trans }}
+
{{ initiative.initiativeType ? initiative.initiativeType.labelKey|trans : '—' }}
+ +
{{ 'initiative.organizational_anchoring'|trans }}
+
{{ initiative.organizationalAnchoring ? initiative.organizationalAnchoring.labelKey|trans : '—' }}
+ +
{{ 'initiative.endorsement'|trans }}
+
{{ initiative.endorsement ? 'common.yes'|trans : 'common.no'|trans }}{% if initiative.endorsementAuthor %} · {{ initiative.endorsementAuthor.labelKey|trans }}{% endif %}
+ +
{{ 'initiative.budget'|trans }}
+
{{ initiative.budget is not null ? initiative.budget|number_format(0, ',', '.') ~ ' kr.' : '—' }}
+ +
{{ 'initiative.funding'|trans }}
+
+ {% if initiative.funding|length > 0 %} + {{ initiative.funding|map(f => f.labelKey|trans)|join(', ') }} + {% else %}—{% endif %} +
+ +
{{ 'initiative.time_period'|trans }}
+
+ {% if initiative.timePeriodStart %} + {{ initiative.timePeriodStart|date('d.m.Y') }}{% if initiative.timePeriodEnd %} – {{ initiative.timePeriodEnd|date('d.m.Y') }}{% endif %} + {% else %}—{% endif %} +
+ +
{{ 'initiative.strategies'|trans }}
+
{{ h.term_list(initiative.strategies) }}
+ +
{{ 'initiative.stakeholders'|trans }}
+
{{ h.term_list(initiative.stakeholders) }}
+ +
{{ 'initiative.tags'|trans }}
+
{{ h.term_list(initiative.tags) }}
+ +
{{ 'initiative.author'|trans }}
+
{{ initiative.author ?: '—' }}
+ +
{{ 'common.created'|trans }}
+
{{ initiative.createdAt|date('d.m.Y H:i') }}
+
+
+
+
+
+{% endblock %} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig new file mode 100644 index 0000000..1e79495 --- /dev/null +++ b/templates/security/login.html.twig @@ -0,0 +1,34 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'login.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} + +{% block body %} +
+
+
+ + {{ 'app.name'|trans }} +
+

{{ 'login.title'|trans }}

+ + {% if error %} +
+ {{ error.messageKey|trans(error.messageData, 'security') }} +
+ {% endif %} + +
+
+ + +
+
+ + +
+ + +
+
+
+{% endblock %} diff --git a/tests/Command/CreateAdminCommandTest.php b/tests/Command/CreateAdminCommandTest.php new file mode 100644 index 0000000..0aa77b4 --- /dev/null +++ b/tests/Command/CreateAdminCommandTest.php @@ -0,0 +1,94 @@ +commandTester(); + + $email = sprintf('cli.admin.%s@example.com', uniqid()); + // The first password is too short (rejected by the validator), then a valid one. + $tester->setInputs(['short', 'longenoughpassword']); + $tester->execute(['email' => $email, 'name' => 'CLI Admin']); + + $tester->assertCommandIsSuccessful(); + + $user = $this->users()->findOneBy(['email' => $email]); + self::assertInstanceOf(User::class, $user); + self::assertSame('CLI Admin', $user->getName()); + self::assertContains('ROLE_ADMIN', $user->getRoles()); + + $this->remove($user); + } + + public function testUpdatesAnExistingUserAndDefaultsNameToEmail(): void + { + $tester = $this->commandTester(); + + $email = sprintf('cli.existing.%s@example.com', uniqid()); + $existing = (new User()) + ->setEmail($email) + ->setName('Old Name') + ->setRoles(['ROLE_USER']) + ->setPassword('old-hash'); + $this->entityManager()->persist($existing); + $this->entityManager()->flush(); + $existingId = $existing->getId(); + + $tester->setInputs(['anothergoodpassword']); + $tester->execute(['email' => $email]); + + $tester->assertCommandIsSuccessful(); + + $this->entityManager()->clear(); + $updated = $this->users()->findOneBy(['email' => $email]); + self::assertInstanceOf(User::class, $updated); + self::assertSame($existingId, $updated->getId(), 'The existing user is updated, not duplicated.'); + self::assertSame($email, $updated->getName(), 'Without a name argument the e-mail is used.'); + self::assertContains('ROLE_ADMIN', $updated->getRoles()); + + $this->remove($updated); + } + + private function commandTester(): CommandTester + { + // bootKernel() returns the (single) booted kernel; the container is then + // shared with the entities created in each test. + $application = new Application(self::bootKernel()); + + return new CommandTester($application->find('app:create-admin')); + } + + private function users(): UserRepository + { + $repository = static::getContainer()->get(UserRepository::class); + \assert($repository instanceof UserRepository); + + return $repository; + } + + private function entityManager(): EntityManagerInterface + { + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + return $em; + } + + private function remove(User $user): void + { + $this->entityManager()->remove($user); + $this->entityManager()->flush(); + } +} diff --git a/tests/Controller/Admin/UserControllerTest.php b/tests/Controller/Admin/UserControllerTest.php new file mode 100644 index 0000000..feca2a8 --- /dev/null +++ b/tests/Controller/Admin/UserControllerTest.php @@ -0,0 +1,133 @@ +loginAsEditor(); + $this->client->request('GET', '/admin/users'); + + $this->assertResponseStatusCodeSame(403); + } + + public function testNewCreatesUser(): void + { + $this->loginAsAdmin(); + $crawler = $this->client->request('GET', '/admin/users/new'); + $this->assertResponseIsSuccessful(); + + $email = sprintf('new.user.%s@example.com', uniqid()); + $token = (string) $crawler->filter('input[name="user[_token]"]')->attr('value'); + $this->client->request('POST', '/admin/users/new', [ + 'user' => [ + 'email' => $email, + 'name' => 'New User', + 'roles' => ['ROLE_USER'], + 'plainPassword' => 'longenoughpassword', + '_token' => $token, + ], + ]); + + $this->assertResponseRedirects('/admin/users'); + + $user = $this->users()->findOneBy(['email' => $email]); + self::assertInstanceOf(User::class, $user); + $this->removeUser((int) $user->getId()); + } + + public function testEditUpdatesUserAndPassword(): void + { + $this->loginAsAdmin(); + $user = $this->createUser(sprintf('editable.%s@example.com', uniqid())); + $id = (int) $user->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/users/%d/edit', $id)); + $this->assertResponseIsSuccessful(); + + $token = (string) $crawler->filter('input[name="user[_token]"]')->attr('value'); + $this->client->request('POST', sprintf('/admin/users/%d/edit', $id), [ + 'user' => [ + 'email' => (string) $user->getEmail(), + 'name' => 'Renamed User', + 'roles' => ['ROLE_USER'], + 'plainPassword' => 'updatedpassword', + '_token' => $token, + ], + ]); + + $this->assertResponseRedirects('/admin/users'); + $this->removeUser($id); + } + + public function testDeletingYourselfIsBlocked(): void + { + $admin = $this->loginAsAdmin(); + $id = (int) $admin->getId(); + + $this->client->request('POST', sprintf('/admin/users/%d/delete', $id), ['_token' => 'whatever']); + + $this->assertResponseRedirects('/admin/users'); + $this->entityManager()->clear(); + self::assertNotNull($this->users()->find($id)); + } + + public function testDeleteRemovesAnotherUserWithAValidToken(): void + { + $this->loginAsAdmin(); + $id = (int) $this->createUser(sprintf('deletable.%s@example.com', uniqid()))->getId(); + + $crawler = $this->client->request('GET', sprintf('/admin/users/%d/edit', $id)); + $form = $crawler->filter('form[action$="/delete"]')->form(); + $this->client->submit($form); + + $this->assertResponseRedirects('/admin/users'); + $this->entityManager()->clear(); + self::assertNull($this->users()->find($id)); + } + + public function testDeleteIgnoresAnInvalidToken(): void + { + $this->loginAsAdmin(); + $id = (int) $this->createUser(sprintf('keep.%s@example.com', uniqid()))->getId(); + + $this->client->request('POST', sprintf('/admin/users/%d/delete', $id), ['_token' => 'invalid']); + + $this->assertResponseRedirects('/admin/users'); + $this->entityManager()->clear(); + self::assertNotNull($this->users()->find($id)); + $this->removeUser($id); + } + + private function createUser(string $email): User + { + $user = (new User()) + ->setEmail($email) + ->setName('Temp User') + ->setRoles(['ROLE_USER']) + ->setPassword('not-a-real-hash'); + + $em = $this->entityManager(); + $em->persist($user); + $em->flush(); + + return $user; + } + + private function removeUser(int $id): void + { + $this->entityManager()->clear(); + $user = $this->users()->find($id); + if (null !== $user) { + $em = $this->entityManager(); + $em->remove($user); + $em->flush(); + } + } +} diff --git a/tests/Controller/ContactControllerTest.php b/tests/Controller/ContactControllerTest.php new file mode 100644 index 0000000..26fea89 --- /dev/null +++ b/tests/Controller/ContactControllerTest.php @@ -0,0 +1,95 @@ +loginAsAdmin(); + $crawler = $this->client->request('GET', '/contacts/new'); + $this->assertResponseIsSuccessful(); + + $form = $crawler->filter('button.btn--primary')->form([ + 'contact[name]' => 'Functional Tester', + 'contact[email]' => 'functional.tester@example.com', + ]); + $this->client->submit($form); + + $this->assertResponseRedirects('/contacts'); + + $em = $this->entityManager(); + foreach ($this->contacts()->findBy(['name' => 'Functional Tester']) as $contact) { + $em->remove($contact); + } + $em->flush(); + } + + public function testEditUpdatesContact(): void + { + $this->loginAsAdmin(); + $id = (int) $this->createContact('Editable Contact')->getId(); + + $crawler = $this->client->request('GET', sprintf('/contacts/%d/edit', $id)); + $this->assertResponseIsSuccessful(); + + $form = $crawler->filter('button.btn--primary')->form(['contact[name]' => 'Edited Contact']); + $this->client->submit($form); + + $this->assertResponseRedirects('/contacts'); + $this->removeContact($id); + } + + public function testDeleteRemovesContactWithAValidToken(): void + { + $this->loginAsAdmin(); + $id = (int) $this->createContact('Deletable Contact')->getId(); + + $crawler = $this->client->request('GET', sprintf('/contacts/%d/edit', $id)); + $form = $crawler->filter('form[action$="/delete"]')->form(); + $this->client->submit($form); + + $this->assertResponseRedirects('/contacts'); + $this->entityManager()->clear(); + self::assertNull($this->contacts()->find($id)); + } + + public function testDeleteIgnoresAnInvalidToken(): void + { + $this->loginAsAdmin(); + $id = (int) $this->createContact('Surviving Contact')->getId(); + + $this->client->request('POST', sprintf('/contacts/%d/delete', $id), ['_token' => 'invalid']); + + $this->assertResponseRedirects('/contacts'); + $this->entityManager()->clear(); + self::assertNotNull($this->contacts()->find($id)); + $this->removeContact($id); + } + + private function createContact(string $name): Contact + { + $contact = (new Contact())->setName($name); + $em = $this->entityManager(); + $em->persist($contact); + $em->flush(); + + return $contact; + } + + private function removeContact(int $id): void + { + $this->entityManager()->clear(); + $contact = $this->contacts()->find($id); + if (null !== $contact) { + $em = $this->entityManager(); + $em->remove($contact); + $em->flush(); + } + } +} diff --git a/tests/Controller/InitiativeControllerTest.php b/tests/Controller/InitiativeControllerTest.php new file mode 100644 index 0000000..b05c56f --- /dev/null +++ b/tests/Controller/InitiativeControllerTest.php @@ -0,0 +1,153 @@ +loginAsAdmin(); + $crawler = $this->client->request('GET', '/initiatives/new'); + $this->assertResponseIsSuccessful(); + + $token = (string) $crawler->filter('input[name="initiative[_token]"]')->attr('value'); + $this->client->request('POST', '/initiatives/new', [ + 'initiative' => [ + 'title' => 'Coverage initiative', + 'newContacts' => [['name' => 'Coverage Contact']], + // An empty image row exercises the image branch of removeEmptyMedia(). + 'images' => [['alt' => 'empty image row']], + '_token' => $token, + ], + ]); + + $this->assertResponseRedirects(); + + $em = $this->entityManager(); + $initiative = $this->initiatives()->findOneBy(['title' => 'Coverage initiative']); + self::assertInstanceOf(Initiative::class, $initiative); + self::assertCount(0, $initiative->getImages(), 'Empty image rows should be dropped.'); + self::assertGreaterThanOrEqual(1, $initiative->getContacts()->count(), 'Inline contact should be merged in.'); + + $em->remove($initiative); + $em->flush(); + + foreach ($this->contacts()->findBy(['name' => 'Coverage Contact']) as $contact) { + $em->remove($contact); + } + $em->flush(); + } + + public function testEditUpdatesInitiative(): void + { + $this->loginAsAdmin(); + $initiative = $this->createInitiative('Editable initiative'); + $id = (int) $initiative->getId(); + + $crawler = $this->client->request('GET', sprintf('/initiatives/%d/edit', $id)); + $this->assertResponseIsSuccessful(); + + $token = (string) $crawler->filter('input[name="initiative[_token]"]')->attr('value'); + $this->client->request('POST', sprintf('/initiatives/%d/edit', $id), [ + 'initiative' => [ + 'title' => 'Edited initiative', + 'images' => [['alt' => 'empty']], + 'attachments' => [[]], + '_token' => $token, + ], + ]); + + $this->assertResponseRedirects(sprintf('/initiatives/%d', $id)); + + $this->removeInitiative($id); + } + + public function testDeleteRemovesInitiativeWithAValidToken(): void + { + $this->loginAsAdmin(); + $initiative = $this->createInitiative('Deletable initiative'); + $id = (int) $initiative->getId(); + + $crawler = $this->client->request('GET', sprintf('/initiatives/%d/edit', $id)); + $form = $crawler->filter('form[action$="/delete"]')->form(); + $this->client->submit($form); + + $this->assertResponseRedirects('/initiatives'); + + $this->entityManager()->clear(); + self::assertNull($this->initiatives()->find($id)); + } + + public function testDeleteIgnoresAnInvalidToken(): void + { + $this->loginAsAdmin(); + $initiative = $this->createInitiative('Survivor initiative'); + $id = (int) $initiative->getId(); + + $this->client->request('POST', sprintf('/initiatives/%d/delete', $id), ['_token' => 'invalid']); + + $this->assertResponseRedirects('/initiatives'); + $this->entityManager()->clear(); + self::assertNotNull($this->initiatives()->find($id)); + + $this->removeInitiative($id); + } + + public function testEditDropsAttachmentsLeftWithoutAFile(): void + { + $this->loginAsAdmin(); + $initiative = $this->createInitiative('Has empty attachment'); + $initiative->addAttachment(new InitiativeAttachment()); + $em = $this->entityManager(); + $em->flush(); + $id = (int) $initiative->getId(); + + $crawler = $this->client->request('GET', sprintf('/initiatives/%d/edit', $id)); + $token = (string) $crawler->filter('input[name="initiative[_token]"]')->attr('value'); + $this->client->request('POST', sprintf('/initiatives/%d/edit', $id), [ + 'initiative' => [ + 'title' => 'Has empty attachment', + // Re-submit the file-less attachment (empty file, no upload) so the + // form keeps it; the controller's removeEmptyMedia() then drops it. + 'attachments' => [['file' => '']], + '_token' => $token, + ], + ]); + + $this->assertResponseRedirects(sprintf('/initiatives/%d', $id)); + + $this->entityManager()->clear(); + $reloaded = $this->initiatives()->find($id); + self::assertNotNull($reloaded); + self::assertCount(0, $reloaded->getAttachments(), 'A file-less attachment should be dropped.'); + + $this->removeInitiative($id); + } + + private function createInitiative(string $title): Initiative + { + $initiative = (new Initiative())->setTitle($title); + $em = $this->entityManager(); + $em->persist($initiative); + $em->flush(); + + return $initiative; + } + + private function removeInitiative(int $id): void + { + $this->entityManager()->clear(); + $initiative = $this->initiatives()->find($id); + if (null !== $initiative) { + $em = $this->entityManager(); + $em->remove($initiative); + $em->flush(); + } + } +} diff --git a/tests/Controller/LocaleControllerTest.php b/tests/Controller/LocaleControllerTest.php new file mode 100644 index 0000000..0da622b --- /dev/null +++ b/tests/Controller/LocaleControllerTest.php @@ -0,0 +1,42 @@ +loginAsAdmin(); + $this->client->request('GET', '/locale/en'); + + $this->assertResponseRedirects('/'); + } + + public function testFollowsSafeRelativeReturnPath(): void + { + $this->loginAsAdmin(); + $this->client->request('GET', '/locale/da?return=/contacts'); + + $this->assertResponseRedirects('/contacts'); + } + + public function testRejectsProtocolRelativeReturnPath(): void + { + $this->loginAsAdmin(); + $this->client->request('GET', '/locale/da?return=//evil.example'); + + $this->assertResponseRedirects('/'); + } + + public function testRejectsBackslashReturnPath(): void + { + $this->loginAsAdmin(); + $this->client->request('GET', '/locale/en?return=/\\evil.example'); + + $this->assertResponseRedirects('/'); + } +} diff --git a/tests/Controller/MediaControllerTest.php b/tests/Controller/MediaControllerTest.php new file mode 100644 index 0000000..fa825b6 --- /dev/null +++ b/tests/Controller/MediaControllerTest.php @@ -0,0 +1,75 @@ +loginAsAdmin(); + $initiative = $this->anyInitiative(); + + $image = (new InitiativeImage())->setAlt('Test image'); + $image->setImageFile($this->upload('sample.png', 'image/png', $this->pngBytes())); + $initiative->addImage($image); + $em = $this->entityManager(); + $em->flush(); + + $this->client->request('GET', sprintf('/media/image/%d', (int) $image->getId())); + $this->assertResponseIsSuccessful(); + + $em->remove($image); + $em->flush(); + } + + public function testAttachmentIsServedToAuthenticatedUsers(): void + { + $this->loginAsAdmin(); + $initiative = $this->anyInitiative(); + + $attachment = new InitiativeAttachment(); + $attachment->setFile($this->upload('document.pdf', 'application/pdf', "%PDF-1.4\n%%EOF\n")); + $initiative->addAttachment($attachment); + $em = $this->entityManager(); + $em->flush(); + + $this->client->request('GET', sprintf('/media/attachment/%d', (int) $attachment->getId())); + $this->assertResponseIsSuccessful(); + + $em->remove($attachment); + $em->flush(); + } + + private function anyInitiative(): Initiative + { + $initiative = $this->initiatives()->findOneBy([]); + self::assertInstanceOf(Initiative::class, $initiative, 'Fixtures should provide at least one initiative.'); + + return $initiative; + } + + private function upload(string $name, string $mimeType, string $contents): UploadedFile + { + $path = tempnam(sys_get_temp_dir(), 'itk'); + self::assertIsString($path); + file_put_contents($path, $contents); + + return new UploadedFile($path, $name, $mimeType, null, true); + } + + private function pngBytes(): string + { + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', true); + self::assertIsString($png); + + return $png; + } +} diff --git a/tests/Controller/SecurityControllerTest.php b/tests/Controller/SecurityControllerTest.php new file mode 100644 index 0000000..ce7634d --- /dev/null +++ b/tests/Controller/SecurityControllerTest.php @@ -0,0 +1,35 @@ +client->request('GET', '/login'); + + $this->assertResponseIsSuccessful(); + self::assertSelectorExists('input[name="email"], input[name="_username"], form'); + } + + public function testLoginRedirectsAlreadyAuthenticatedUsers(): void + { + $this->loginAsAdmin(); + $this->client->request('GET', '/login'); + + $this->assertResponseRedirects('/'); + } + + public function testLogoutIsHandledByTheFirewall(): void + { + $this->loginAsAdmin(); + $this->client->request('GET', '/logout'); + + // The firewall intercepts /logout and redirects to the login target. + $this->assertResponseRedirects(); + } +} diff --git a/tests/Controller/SmokeTest.php b/tests/Controller/SmokeTest.php new file mode 100644 index 0000000..fb5ee5d --- /dev/null +++ b/tests/Controller/SmokeTest.php @@ -0,0 +1,100 @@ +request('GET', '/'); + $this->assertResponseRedirects(); + } + + #[DataProvider('authenticatedPages')] + public function testAuthenticatedPagesRender(string $url): void + { + $client = static::createClient(); + $admin = static::getContainer()->get(UserRepository::class)->findOneBy(['email' => 'admin@example.com']); + self::assertNotNull($admin, 'Load fixtures into the test database first.'); + + $client->loginUser($admin); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(sprintf('GET %s should succeed', $url)); + } + + public function testInitiativeShowAndEditRender(): void + { + $client = static::createClient(); + $container = static::getContainer(); + + $admin = $container->get(UserRepository::class)->findOneBy(['email' => 'admin@example.com']); + self::assertNotNull($admin); + $client->loginUser($admin); + + $initiative = $container->get(InitiativeRepository::class)->findOneBy([]); + self::assertNotNull($initiative, 'Fixtures should create at least one initiative.'); + + $client->request('GET', sprintf('/initiatives/%d', $initiative->getId())); + $this->assertResponseIsSuccessful(); + + $client->request('GET', sprintf('/initiatives/%d/edit', $initiative->getId())); + $this->assertResponseIsSuccessful(); + } + + public function testImageUploadIsStored(): void + { + static::createClient(); + $container = static::getContainer(); + $entityManager = $container->get(EntityManagerInterface::class); + + $initiative = $container->get(InitiativeRepository::class)->findOneBy([]); + self::assertNotNull($initiative); + + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', true); + self::assertIsString($png); + $path = tempnam(sys_get_temp_dir(), 'itk').'.png'; + file_put_contents($path, $png); + $upload = new UploadedFile($path, 'sample.png', 'image/png', null, true); + + $image = (new InitiativeImage())->setAlt('Sample'); + $image->setImageFile($upload); + $initiative->addImage($image); + $entityManager->flush(); + + self::assertNotNull($image->getId()); + self::assertNotNull($image->getImageName(), 'Vich should persist the stored file name.'); + self::assertSame('sample.png', $image->getOriginalName()); + + // Keep the suite idempotent (and let Vich delete the stored file). + $entityManager->remove($image); + $entityManager->flush(); + } + + /** + * @return iterable + */ + public static function authenticatedPages(): iterable + { + yield 'dashboard' => ['/']; + yield 'initiatives' => ['/initiatives']; + yield 'initiatives filtered' => ['/initiatives?status=active&endorsement=1&sort=title&direction=ASC']; + yield 'initiative new' => ['/initiatives/new']; + yield 'csv export' => ['/initiatives/export']; + yield 'contacts' => ['/contacts']; + yield 'contact new' => ['/contacts/new']; + yield 'admin users' => ['/admin/users']; + yield 'admin user new' => ['/admin/users/new']; + } +} diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php new file mode 100644 index 0000000..134e2d7 --- /dev/null +++ b/tests/FunctionalTestCase.php @@ -0,0 +1,89 @@ +client = static::createClient(); + } + + protected function entityManager(): EntityManagerInterface + { + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + return $em; + } + + protected function users(): UserRepository + { + $repository = static::getContainer()->get(UserRepository::class); + \assert($repository instanceof UserRepository); + + return $repository; + } + + protected function initiatives(): InitiativeRepository + { + $repository = static::getContainer()->get(InitiativeRepository::class); + \assert($repository instanceof InitiativeRepository); + + return $repository; + } + + protected function contacts(): ContactRepository + { + $repository = static::getContainer()->get(ContactRepository::class); + \assert($repository instanceof ContactRepository); + + return $repository; + } + + protected function terms(): TermRepository + { + $repository = static::getContainer()->get(TermRepository::class); + \assert($repository instanceof TermRepository); + + return $repository; + } + + protected function loginAsAdmin(): User + { + return $this->login('admin@example.com'); + } + + protected function loginAsEditor(): User + { + return $this->login('editor@example.com'); + } + + protected function login(string $email): User + { + $user = $this->users()->findOneBy(['email' => $email]); + self::assertInstanceOf(User::class, $user, sprintf('User "%s" not found — load fixtures into the test database first.', $email)); + $this->client->loginUser($user); + + return $user; + } +} diff --git a/tests/Repository/ContactRepositoryTest.php b/tests/Repository/ContactRepositoryTest.php new file mode 100644 index 0000000..cf7d21c --- /dev/null +++ b/tests/Repository/ContactRepositoryTest.php @@ -0,0 +1,26 @@ +get(ContactRepository::class); + \assert($repository instanceof ContactRepository); + + $contacts = $repository->findAllOrdered(); + + // Ordering is delegated to the database collation, so we only assert the + // method returns the expected set of entities. + self::assertNotEmpty($contacts); + self::assertContainsOnlyInstancesOf(Contact::class, $contacts); + } +} diff --git a/tests/Repository/InitiativeRepositoryTest.php b/tests/Repository/InitiativeRepositoryTest.php new file mode 100644 index 0000000..3cd024e --- /dev/null +++ b/tests/Repository/InitiativeRepositoryTest.php @@ -0,0 +1,101 @@ +get(InitiativeRepository::class); + \assert($repository instanceof InitiativeRepository); + $this->repository = $repository; + } + + public function testSearchAppliesEveryFilterBranch(): void + { + $filter = new InitiativeFilter(); + $filter->q = '100%_'; // also exercises LIKE wildcard escaping + $filter->status = Status::Active; + $filter->category = Category::Climate; + $filter->initiativeType = InitiativeType::Project; + $filter->organizationalAnchoring = OrganizationalAnchoring::HealthAndCare; + $filter->endorsement = true; + $filter->budgetMin = 0; + $filter->budgetMax = 1_000_000_000; + $filter->sort = 'title'; + $filter->direction = 'ASC'; + + self::assertIsArray($this->repository->search($filter)->getQuery()->getResult()); + } + + public function testSearchFallsBackForUnknownSortAndDirection(): void + { + $filter = new InitiativeFilter(); + $filter->sort = 'not-a-column'; + $filter->direction = 'sideways'; + + self::assertIsArray($this->repository->search($filter)->getQuery()->getResult()); + } + + public function testFindForExportReturnsEmptyArrayWhenNothingMatches(): void + { + $filter = new InitiativeFilter(); + $filter->q = 'no-such-initiative-'.uniqid(); + + self::assertSame([], $this->repository->findForExport($filter)); + } + + public function testFindForExportPrimesCollections(): void + { + $rows = $this->repository->findForExport(new InitiativeFilter()); + + self::assertNotEmpty($rows); + self::assertContainsOnlyInstancesOf(Initiative::class, $rows); + self::assertIsIterable($rows[0]->getTags()); + } + + public function testCountAll(): void + { + self::assertGreaterThan(0, $this->repository->countAll()); + } + + public function testCountByStatusSkipsInitiativesWithoutStatus(): void + { + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $initiative = (new Initiative())->setTitle('No status '.uniqid()); + $em->persist($initiative); + $em->flush(); + + $counts = $this->repository->countByStatus(); + self::assertIsArray($counts); + foreach ($counts as $key => $count) { + self::assertIsString($key); + self::assertIsInt($count); + } + + $em->remove($initiative); + $em->flush(); + } + + public function testFindRecentRespectsTheLimit(): void + { + self::assertLessThanOrEqual(3, \count($this->repository->findRecent(3))); + } +} diff --git a/tests/Repository/TermRepositoryTest.php b/tests/Repository/TermRepositoryTest.php new file mode 100644 index 0000000..4bf2c5d --- /dev/null +++ b/tests/Repository/TermRepositoryTest.php @@ -0,0 +1,52 @@ +get(TermRepository::class); + \assert($repository instanceof TermRepository); + $this->repository = $repository; + } + + public function testFindByVocabularyReturnsOnlyMatchingTerms(): void + { + $terms = $this->repository->findByVocabulary(Vocabulary::Tag); + + self::assertNotEmpty($terms); + foreach ($terms as $term) { + self::assertSame(Vocabulary::Tag, $term->getVocabulary()); + } + } + + public function testFindOrCreateReturnsAnExistingTermCaseInsensitively(): void + { + // The fixtures create a "Klima" tag. + $term = $this->repository->findOrCreate('klima', Vocabulary::Tag); + + self::assertSame('Klima', $term->getName()); + self::assertNotNull($term->getId()); + } + + public function testFindOrCreateBuildsANewUnflushedTerm(): void + { + $name = 'BrandNewTag-'.uniqid(); + + $term = $this->repository->findOrCreate($name, Vocabulary::Strategy); + + self::assertSame($name, $term->getName()); + self::assertSame(Vocabulary::Strategy, $term->getVocabulary()); + self::assertNull($term->getId(), 'A freshly created term is persisted but not yet flushed.'); + } +} diff --git a/tests/Repository/UserRepositoryTest.php b/tests/Repository/UserRepositoryTest.php new file mode 100644 index 0000000..f781189 --- /dev/null +++ b/tests/Repository/UserRepositoryTest.php @@ -0,0 +1,61 @@ +get(UserRepository::class); + \assert($repository instanceof UserRepository); + $this->repository = $repository; + + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + $this->em = $em; + } + + public function testUpgradePasswordStoresTheNewHash(): void + { + $user = (new User()) + ->setEmail('upgrade.'.uniqid().'@example.com') + ->setName('Upgrade') + ->setRoles(['ROLE_USER']) + ->setPassword('old-hash'); + $this->em->persist($user); + $this->em->flush(); + + $this->repository->upgradePassword($user, 'new-hash'); + + self::assertSame('new-hash', $user->getPassword()); + + $this->em->remove($user); + $this->em->flush(); + } + + public function testUpgradePasswordRejectsUnsupportedUsers(): void + { + $unsupported = new class implements PasswordAuthenticatedUserInterface { + public function getPassword(): ?string + { + return null; + } + }; + + $this->expectException(UnsupportedUserException::class); + $this->repository->upgradePassword($unsupported, 'hash'); + } +} diff --git a/tests/Service/PaginatorTest.php b/tests/Service/PaginatorTest.php new file mode 100644 index 0000000..80801a6 --- /dev/null +++ b/tests/Service/PaginatorTest.php @@ -0,0 +1,47 @@ +get(Paginator::class); + \assert($paginator instanceof Paginator); + $this->paginator = $paginator; + + $initiatives = static::getContainer()->get(InitiativeRepository::class); + \assert($initiatives instanceof InitiativeRepository); + $this->initiatives = $initiatives; + } + + public function testClampsPageBeyondTheLastPage(): void + { + $result = $this->paginator->paginate($this->initiatives->search(new InitiativeFilter()), 999, 5); + + self::assertGreaterThan(0, $result->total); + self::assertSame($result->pages, $result->page, 'A page beyond the range is clamped to the last page.'); + self::assertLessThanOrEqual(5, \count($result->items)); + self::assertNotEmpty($result->items, 'The clamped last page should still return rows.'); + } + + public function testClampsPageBelowOne(): void + { + $result = $this->paginator->paginate($this->initiatives->search(new InitiativeFilter()), 0, 5); + + self::assertSame(1, $result->page); + self::assertSame(5, $result->perPage); + } +} diff --git a/tests/Unit/Entity/ContactTest.php b/tests/Unit/Entity/ContactTest.php new file mode 100644 index 0000000..5d130ed --- /dev/null +++ b/tests/Unit/Entity/ContactTest.php @@ -0,0 +1,48 @@ +getId()); + self::assertNull($contact->getName()); + self::assertNull($contact->getEmail()); + self::assertNull($contact->getPhone()); + self::assertNull($contact->getDepartment()); + self::assertInstanceOf(\DateTimeImmutable::class, $contact->getCreatedAt()); + self::assertSame('', (string) $contact); + } + + public function testAccessors(): void + { + $contact = (new Contact()) + ->setName('Anne Jensen') + ->setEmail('anne@example.com') + ->setPhone('+45 12 34 56 78') + ->setDepartment('Teknik og Miljø'); + + self::assertSame('Anne Jensen', $contact->getName()); + self::assertSame('anne@example.com', $contact->getEmail()); + self::assertSame('+45 12 34 56 78', $contact->getPhone()); + self::assertSame('Teknik og Miljø', $contact->getDepartment()); + self::assertSame('Anne Jensen', (string) $contact); + } + + public function testTouchUpdatesTimestamp(): void + { + $contact = new Contact(); + $contact->touch(); + + // touch() does not throw and leaves the entity in a valid state. + self::assertInstanceOf(\DateTimeImmutable::class, $contact->getCreatedAt()); + } +} diff --git a/tests/Unit/Entity/InitiativeAttachmentTest.php b/tests/Unit/Entity/InitiativeAttachmentTest.php new file mode 100644 index 0000000..4c88ac6 --- /dev/null +++ b/tests/Unit/Entity/InitiativeAttachmentTest.php @@ -0,0 +1,52 @@ +setInitiative($initiative) + ->setFileName('stored.pdf') + ->setOriginalName('report.pdf') + ->setMimeType('application/pdf') + ->setSize(4096); + + self::assertNull($attachment->getId()); + self::assertSame($initiative, $attachment->getInitiative()); + self::assertSame('stored.pdf', $attachment->getFileName()); + self::assertSame('report.pdf', $attachment->getOriginalName()); + self::assertSame('application/pdf', $attachment->getMimeType()); + self::assertSame(4096, $attachment->getSize()); + } + + public function testSettingAFileMarksItDirty(): void + { + $attachment = new InitiativeAttachment(); + self::assertNull($attachment->getFile()); + self::assertFalse($attachment->hasFile()); + + $attachment->setFile(new File(__FILE__)); + self::assertInstanceOf(File::class, $attachment->getFile()); + self::assertTrue($attachment->hasFile()); + + $attachment->setFile(null); + self::assertNull($attachment->getFile()); + } + + public function testHasFileIsTrueWhenOnlyAStoredNameIsPresent(): void + { + $attachment = (new InitiativeAttachment())->setFileName('stored.pdf'); + + self::assertTrue($attachment->hasFile()); + } +} diff --git a/tests/Unit/Entity/InitiativeImageTest.php b/tests/Unit/Entity/InitiativeImageTest.php new file mode 100644 index 0000000..096700f --- /dev/null +++ b/tests/Unit/Entity/InitiativeImageTest.php @@ -0,0 +1,54 @@ +setInitiative($initiative) + ->setImageName('stored.png') + ->setOriginalName('sample.png') + ->setMimeType('image/png') + ->setSize(1234) + ->setAlt('A sample'); + + self::assertNull($image->getId()); + self::assertSame($initiative, $image->getInitiative()); + self::assertSame('stored.png', $image->getImageName()); + self::assertSame('sample.png', $image->getOriginalName()); + self::assertSame('image/png', $image->getMimeType()); + self::assertSame(1234, $image->getSize()); + self::assertSame('A sample', $image->getAlt()); + } + + public function testSettingAFileMarksItDirty(): void + { + $image = new InitiativeImage(); + self::assertNull($image->getImageFile()); + self::assertFalse($image->hasFile()); + + $image->setImageFile(new File(__FILE__)); + self::assertInstanceOf(File::class, $image->getImageFile()); + self::assertTrue($image->hasFile()); + + $image->setImageFile(null); + self::assertNull($image->getImageFile()); + } + + public function testHasFileIsTrueWhenOnlyAStoredNameIsPresent(): void + { + $image = (new InitiativeImage())->setImageName('stored.png'); + + self::assertTrue($image->hasFile()); + } +} diff --git a/tests/Unit/Entity/InitiativeTest.php b/tests/Unit/Entity/InitiativeTest.php new file mode 100644 index 0000000..4e214e8 --- /dev/null +++ b/tests/Unit/Entity/InitiativeTest.php @@ -0,0 +1,203 @@ +getId()); + self::assertNull($initiative->getTitle()); + self::assertTrue($initiative->isEndorsement()); + self::assertSame([], $initiative->getFunding()); + self::assertSame([], $initiative->getLinks()); + self::assertCount(0, $initiative->getStrategies()); + self::assertCount(0, $initiative->getStakeholders()); + self::assertCount(0, $initiative->getTags()); + self::assertCount(0, $initiative->getContacts()); + self::assertCount(0, $initiative->getImages()); + self::assertCount(0, $initiative->getAttachments()); + self::assertInstanceOf(\DateTimeImmutable::class, $initiative->getCreatedAt()); + self::assertInstanceOf(\DateTimeImmutable::class, $initiative->getUpdatedAt()); + self::assertSame('', (string) $initiative); + } + + public function testScalarAccessors(): void + { + $start = new \DateTimeImmutable('2025-01-01'); + $end = new \DateTimeImmutable('2025-12-31'); + + $initiative = (new Initiative()) + ->setTitle('Grøn omstilling') + ->setCategory(Category::Climate) + ->setDescription('Beskrivelse') + ->setInitiativeType(InitiativeType::Project) + ->setStatus(Status::Active) + ->setStatusAdditional('Igangsat') + ->setOrganizationalAnchoring(OrganizationalAnchoring::TechnicalAndEnvironment) + ->setEndorsement(false) + ->setEndorsementAuthor(EndorsementAuthor::CityCouncil) + ->setBudget(500000) + ->setTimePeriodStart($start) + ->setTimePeriodEnd($end) + ->setAuthor('Anne Jensen'); + + self::assertSame('Grøn omstilling', $initiative->getTitle()); + self::assertSame(Category::Climate, $initiative->getCategory()); + self::assertSame('Beskrivelse', $initiative->getDescription()); + self::assertSame(InitiativeType::Project, $initiative->getInitiativeType()); + self::assertSame(Status::Active, $initiative->getStatus()); + self::assertSame('Igangsat', $initiative->getStatusAdditional()); + self::assertSame(OrganizationalAnchoring::TechnicalAndEnvironment, $initiative->getOrganizationalAnchoring()); + self::assertFalse($initiative->isEndorsement()); + self::assertSame(EndorsementAuthor::CityCouncil, $initiative->getEndorsementAuthor()); + self::assertSame(500000, $initiative->getBudget()); + self::assertSame($start, $initiative->getTimePeriodStart()); + self::assertSame($end, $initiative->getTimePeriodEnd()); + self::assertSame('Anne Jensen', $initiative->getAuthor()); + self::assertSame('Grøn omstilling', (string) $initiative); + } + + public function testFundingRoundTrip(): void + { + $initiative = (new Initiative())->setFunding([Funding::MunicipalBudget, Funding::EuFunds]); + + self::assertSame([Funding::MunicipalBudget, Funding::EuFunds], $initiative->getFunding()); + } + + public function testLinksKeepOnlyHttpUrls(): void + { + $initiative = (new Initiative())->setLinks([ + '', + 'https://ok.example', + 'javascript:alert(1)', + ' ', + 'http://plain.example', + ]); + + self::assertSame(['https://ok.example', 'http://plain.example'], $initiative->getLinks()); + } + + public function testStrategyCollection(): void + { + $initiative = new Initiative(); + $term = new Term(Vocabulary::Strategy); + + $initiative->addStrategy($term); + $initiative->addStrategy($term); + self::assertCount(1, $initiative->getStrategies()); + + $initiative->removeStrategy($term); + self::assertCount(0, $initiative->getStrategies()); + + $initiative->setStrategies([new Term(Vocabulary::Strategy), new Term(Vocabulary::Strategy)]); + self::assertCount(2, $initiative->getStrategies()); + $initiative->setStrategies([]); + self::assertCount(0, $initiative->getStrategies()); + } + + public function testStakeholderCollection(): void + { + $initiative = new Initiative(); + $term = new Term(Vocabulary::Stakeholder); + + $initiative->addStakeholder($term); + $initiative->addStakeholder($term); + self::assertCount(1, $initiative->getStakeholders()); + + $initiative->removeStakeholder($term); + self::assertCount(0, $initiative->getStakeholders()); + + $initiative->setStakeholders([new Term(Vocabulary::Stakeholder)]); + self::assertCount(1, $initiative->getStakeholders()); + $initiative->setStakeholders([]); + self::assertCount(0, $initiative->getStakeholders()); + } + + public function testTagCollection(): void + { + $initiative = new Initiative(); + $term = new Term(Vocabulary::Tag); + + $initiative->addTag($term); + $initiative->addTag($term); + self::assertCount(1, $initiative->getTags()); + + $initiative->removeTag($term); + self::assertCount(0, $initiative->getTags()); + + $initiative->setTags([new Term(Vocabulary::Tag), new Term(Vocabulary::Tag)]); + self::assertCount(2, $initiative->getTags()); + $initiative->setTags([]); + self::assertCount(0, $initiative->getTags()); + } + + public function testContactCollection(): void + { + $initiative = new Initiative(); + $contact = (new Contact())->setName('Anne'); + + $initiative->addContact($contact); + $initiative->addContact($contact); + self::assertCount(1, $initiative->getContacts()); + + $initiative->removeContact($contact); + self::assertCount(0, $initiative->getContacts()); + } + + public function testImageCollectionLinksBackToInitiative(): void + { + $initiative = new Initiative(); + $image = new InitiativeImage(); + + $initiative->addImage($image); + $initiative->addImage($image); + self::assertCount(1, $initiative->getImages()); + self::assertSame($initiative, $image->getInitiative()); + + $initiative->removeImage($image); + self::assertCount(0, $initiative->getImages()); + } + + public function testAttachmentCollectionLinksBackToInitiative(): void + { + $initiative = new Initiative(); + $attachment = new InitiativeAttachment(); + + $initiative->addAttachment($attachment); + $initiative->addAttachment($attachment); + self::assertCount(1, $initiative->getAttachments()); + self::assertSame($initiative, $attachment->getInitiative()); + + $initiative->removeAttachment($attachment); + self::assertCount(0, $initiative->getAttachments()); + } + + public function testTouchAdvancesUpdatedAt(): void + { + $initiative = new Initiative(); + $before = $initiative->getUpdatedAt(); + + $initiative->touch(); + + self::assertGreaterThanOrEqual($before, $initiative->getUpdatedAt()); + } +} diff --git a/tests/Unit/Entity/TermTest.php b/tests/Unit/Entity/TermTest.php new file mode 100644 index 0000000..2849dbd --- /dev/null +++ b/tests/Unit/Entity/TermTest.php @@ -0,0 +1,35 @@ +getId()); + self::assertNull($term->getName()); + self::assertSame(Vocabulary::Tag, $term->getVocabulary()); + self::assertInstanceOf(\DateTimeImmutable::class, $term->getCreatedAt()); + self::assertSame('', (string) $term); + } + + public function testAccessors(): void + { + $term = (new Term(Vocabulary::Stakeholder))->setName('Aarhus Kommune'); + + self::assertSame('Aarhus Kommune', $term->getName()); + self::assertSame(Vocabulary::Stakeholder, $term->getVocabulary()); + self::assertSame('Aarhus Kommune', (string) $term); + + $term->setVocabulary(Vocabulary::Strategy); + self::assertSame(Vocabulary::Strategy, $term->getVocabulary()); + } +} diff --git a/tests/Unit/Entity/UserTest.php b/tests/Unit/Entity/UserTest.php new file mode 100644 index 0000000..c53d8d8 --- /dev/null +++ b/tests/Unit/Entity/UserTest.php @@ -0,0 +1,62 @@ +getId()); + self::assertNull($user->getEmail()); + self::assertNull($user->getPassword()); + self::assertSame('', $user->getUserIdentifier()); + self::assertSame([], array_diff($user->getRoles(), ['ROLE_USER'])); + } + + public function testAccessors(): void + { + $user = (new User()) + ->setEmail('admin@example.com') + ->setName('Administrator') + ->setPassword('hashed'); + + self::assertSame('admin@example.com', $user->getEmail()); + self::assertSame('Administrator', $user->getName()); + self::assertSame('admin@example.com', $user->getUserIdentifier()); + self::assertSame('hashed', $user->getPassword()); + } + + public function testNameFallsBackToEmailWhenEmpty(): void + { + $user = (new User())->setEmail('editor@example.com'); + + self::assertSame('editor@example.com', $user->getName()); + } + + public function testGetRolesAlwaysContainsRoleUserAndIsDeduplicated(): void + { + $user = (new User())->setRoles(['ROLE_ADMIN', 'ROLE_USER']); + + $roles = $user->getRoles(); + + self::assertContains('ROLE_USER', $roles); + self::assertContains('ROLE_ADMIN', $roles); + self::assertSame(array_values(array_unique($roles)), $roles); + self::assertCount(2, $roles); + } + + public function testEraseCredentialsDoesNothing(): void + { + $user = new User(); + $user->eraseCredentials(); + + $this->expectNotToPerformAssertions(); + } +} diff --git a/tests/Unit/Enum/TranslatableEnumTest.php b/tests/Unit/Enum/TranslatableEnumTest.php new file mode 100644 index 0000000..d1836ee --- /dev/null +++ b/tests/Unit/Enum/TranslatableEnumTest.php @@ -0,0 +1,41 @@ +assertLabelKeys(Category::cases(), 'enum.category.'); + $this->assertLabelKeys(EndorsementAuthor::cases(), 'enum.endorsement_author.'); + $this->assertLabelKeys(Funding::cases(), 'enum.funding.'); + $this->assertLabelKeys(InitiativeType::cases(), 'enum.initiative_type.'); + $this->assertLabelKeys(OrganizationalAnchoring::cases(), 'enum.organizational_anchoring.'); + $this->assertLabelKeys(Status::cases(), 'enum.status.'); + $this->assertLabelKeys(Vocabulary::cases(), 'enum.vocabulary.'); + } + + /** + * @param array $cases + */ + private function assertLabelKeys(array $cases, string $prefix): void + { + self::assertNotEmpty($cases); + + foreach ($cases as $case) { + self::assertSame($prefix.$case->value, $case->labelKey()); + } + } +} diff --git a/tests/Unit/EventListener/LocaleSubscriberTest.php b/tests/Unit/EventListener/LocaleSubscriberTest.php new file mode 100644 index 0000000..29e045c --- /dev/null +++ b/tests/Unit/EventListener/LocaleSubscriberTest.php @@ -0,0 +1,72 @@ +requestWithPreviousSession(['_locale' => 'en']); + $this->dispatch($request); + + self::assertSame('en', $request->getLocale()); + } + + public function testFallsBackToDefaultLocaleWhenNoneStored(): void + { + $request = $this->requestWithPreviousSession([]); + $this->dispatch($request); + + self::assertSame('da', $request->getLocale()); + } + + public function testDoesNothingWithoutAPreviousSession(): void + { + $request = new Request(); + $request->setLocale('en'); + $this->dispatch($request); + + self::assertSame('en', $request->getLocale()); + } + + /** + * @param array $sessionData + */ + private function requestWithPreviousSession(array $sessionData): Request + { + $session = new Session(new MockArraySessionStorage()); + foreach ($sessionData as $key => $value) { + $session->set($key, $value); + } + + $request = new Request(); + $request->setSession($session); + // A matching session cookie makes Request::hasPreviousSession() return true. + $request->cookies->set($session->getName(), 'test'); + + return $request; + } + + private function dispatch(Request $request): void + { + $event = new RequestEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST); + + (new LocaleSubscriber())->onKernelRequest($event); + } +} diff --git a/tests/Unit/EventListener/SecurityHeadersSubscriberTest.php b/tests/Unit/EventListener/SecurityHeadersSubscriberTest.php new file mode 100644 index 0000000..0c2bb12 --- /dev/null +++ b/tests/Unit/EventListener/SecurityHeadersSubscriberTest.php @@ -0,0 +1,56 @@ +handle(new SecurityHeadersSubscriber(debug: false), HttpKernelInterface::MAIN_REQUEST); + + self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options')); + self::assertSame('DENY', $response->headers->get('X-Frame-Options')); + self::assertSame('strict-origin-when-cross-origin', $response->headers->get('Referrer-Policy')); + self::assertStringContainsString("default-src 'self'", (string) $response->headers->get('Content-Security-Policy')); + } + + public function testOmitsCspInDebug(): void + { + $response = $this->handle(new SecurityHeadersSubscriber(debug: true), HttpKernelInterface::MAIN_REQUEST); + + self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options')); + self::assertFalse($response->headers->has('Content-Security-Policy')); + } + + public function testIgnoresSubRequests(): void + { + $response = $this->handle(new SecurityHeadersSubscriber(debug: false), HttpKernelInterface::SUB_REQUEST); + + self::assertFalse($response->headers->has('X-Content-Type-Options')); + } + + private function handle(SecurityHeadersSubscriber $subscriber, int $requestType): Response + { + $response = new Response(); + $event = new ResponseEvent($this->createStub(HttpKernelInterface::class), new Request(), $requestType, $response); + + $subscriber->onKernelResponse($event); + + return $response; + } +} diff --git a/tests/Unit/Form/DataTransformer/TermsTextTransformerTest.php b/tests/Unit/Form/DataTransformer/TermsTextTransformerTest.php new file mode 100644 index 0000000..bf6dee2 --- /dev/null +++ b/tests/Unit/Form/DataTransformer/TermsTextTransformerTest.php @@ -0,0 +1,57 @@ +transformer()->transform(null)); + } + + public function testTransformJoinsTermNames(): void + { + $terms = [ + (new Term(Vocabulary::Tag))->setName('Klima'), + (new Term(Vocabulary::Tag))->setName('Data'), + ]; + + self::assertSame('Klima, Data', $this->transformer()->transform($terms)); + } + + public function testReverseTransformOfNonStringReturnsEmptyCollection(): void + { + self::assertCount(0, $this->transformer()->reverseTransform(null)); + } + + public function testReverseTransformOfBlankReturnsEmptyCollection(): void + { + self::assertCount(0, $this->transformer()->reverseTransform(' ')); + } + + public function testReverseTransformTrimsDeduplicatesAndCreatesTerms(): void + { + $repository = $this->createMock(TermRepository::class); + $repository->expects(self::exactly(2)) + ->method('findOrCreate') + ->willReturnCallback(static fn (string $name, Vocabulary $vocabulary): Term => (new Term($vocabulary))->setName($name)); + + $transformer = new TermsTextTransformer($repository, Vocabulary::Tag); + + // "klima" duplicates "Klima" (case-insensitive) and the empty segment is skipped. + self::assertCount(2, $transformer->reverseTransform('Klima, Data, , klima')); + } + + private function transformer(): TermsTextTransformer + { + return new TermsTextTransformer($this->createStub(TermRepository::class), Vocabulary::Tag); + } +} diff --git a/tests/Unit/Model/InitiativeFilterTest.php b/tests/Unit/Model/InitiativeFilterTest.php new file mode 100644 index 0000000..670cd3f --- /dev/null +++ b/tests/Unit/Model/InitiativeFilterTest.php @@ -0,0 +1,52 @@ +q); + self::assertNull($filter->status); + self::assertNull($filter->category); + self::assertNull($filter->initiativeType); + self::assertNull($filter->organizationalAnchoring); + self::assertNull($filter->endorsement); + self::assertNull($filter->budgetMin); + self::assertNull($filter->budgetMax); + self::assertSame('createdAt', $filter->sort); + self::assertSame('DESC', $filter->direction); + } + + public function testIsMutable(): void + { + $filter = new InitiativeFilter(); + $filter->q = 'klima'; + $filter->status = Status::Active; + $filter->category = Category::Climate; + $filter->initiativeType = InitiativeType::Project; + $filter->organizationalAnchoring = OrganizationalAnchoring::HealthAndCare; + $filter->endorsement = true; + $filter->budgetMin = 1000; + $filter->budgetMax = 5000; + $filter->sort = 'title'; + $filter->direction = 'ASC'; + + self::assertSame('klima', $filter->q); + self::assertSame(Status::Active, $filter->status); + self::assertTrue($filter->endorsement); + self::assertSame(1000, $filter->budgetMin); + self::assertSame(5000, $filter->budgetMax); + } +} diff --git a/tests/Unit/Service/PaginationResultTest.php b/tests/Unit/Service/PaginationResultTest.php new file mode 100644 index 0000000..d41c758 --- /dev/null +++ b/tests/Unit/Service/PaginationResultTest.php @@ -0,0 +1,51 @@ +items); + self::assertTrue($result->hasPrevious()); + self::assertTrue($result->hasNext()); + self::assertSame(26, $result->firstResult()); + self::assertSame(50, $result->lastResult()); + } + + public function testFirstPage(): void + { + $result = new PaginationResult(items: [], page: 1, pages: 3, total: 60, perPage: 25); + + self::assertFalse($result->hasPrevious()); + self::assertTrue($result->hasNext()); + self::assertSame(1, $result->firstResult()); + self::assertSame(25, $result->lastResult()); + } + + public function testLastPage(): void + { + $result = new PaginationResult(items: [], page: 3, pages: 3, total: 60, perPage: 25); + + self::assertTrue($result->hasPrevious()); + self::assertFalse($result->hasNext()); + self::assertSame(60, $result->lastResult()); + } + + public function testEmptyResultHasNoFirstRow(): void + { + $result = new PaginationResult(items: [], page: 1, pages: 1, total: 0, perPage: 25); + + self::assertFalse($result->hasPrevious()); + self::assertFalse($result->hasNext()); + self::assertSame(0, $result->firstResult()); + self::assertSame(0, $result->lastResult()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..47a5855 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG']) { + umask(0000); +} diff --git a/translations/.gitignore b/translations/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml new file mode 100644 index 0000000..0742488 --- /dev/null +++ b/translations/messages.da.yaml @@ -0,0 +1,223 @@ +app: + name: Projektdatabase + +nav: + dashboard: Overblik + initiatives: Initiativer + contacts: Kontaktpersoner + users: Brugere + admin: Administration + language: Sprog + signed_in_as: Logget ind som + logout: Log ud + +action: + new: Opret + edit: Rediger + delete: Slet + save: Gem + cancel: Annuller + create: Opret + back: Tilbage + view: Vis + add: Tilføj + filter: Filtrer + reset: Nulstil + export: Eksportér CSV + add_link: Tilføj link + add_contact: Tilføj kontaktperson + add_image: Tilføj billede + add_attachment: Tilføj fil + remove: Fjern + confirm_delete: Er du sikker på, at du vil slette dette? + +common: + yes: Ja + no: Nej + none: Ingen + actions: Handlinger + created: Oprettet + updated: Opdateret + optional: valgfri + +form: + choose: Vælg … + terms: + invalid: Ugyldige værdier. + +filter: + search: Søg + search_placeholder: Søg i titel, beskrivelse, forfatter … + all: Alle + yes: "Ja" + no: "Nej" + budget_min: Budget fra + budget_max: Budget til + +dashboard: + title: Overblik + subtitle: Status på alle initiativer i databasen. + total: Initiativer i alt + by_status: Fordeling på status + recent: Senest oprettede + no_status: Uden status + empty: Der er endnu ikke oprettet nogen initiativer. + +initiative: + title: Titel + category: Kategori + description: Kort beskrivelse + strategies: Strategier og planer + initiative_type: Initiativets karakter + status: Status + status_additional: Status – supplerende tekst + organizational_anchoring: Organisatorisk forankring + endorsement: Er initiativet vedtaget + endorsement_author: Hvor er det vedtaget + budget: Budget (kr.) + funding: Fundingkilder + stakeholders: Interessenter og samarbejdspartnere + tags: Tags + time_period_start: Tidshorisont – start + time_period_end: Tidshorisont – slut + time_period: Tidshorisont + links: Relevante links + contacts: Kontaktpersoner + new_contacts: Opret nye kontaktpersoner + author: Udfyldt af + terms_help: Adskil flere værdier med komma. + images: Billeder + attachments: Filer + image_file: Billedfil + image_alt: Alternativ tekst + attachment_file: Fil + attachment_invalid_type: "Filtypen er ikke tilladt (tilladt: pdf, doc, docx, xls, xlsx)." + index: + title: Initiativer + subtitle: Søg, filtrér og opret initiativer. + new: + title: Nyt initiativ + edit: + title: Rediger initiativ + show: + details: Detaljer + contacts: Kontaktpersoner + classification: Klassificering + media: Filer og billeder + no_contacts: Ingen kontaktpersoner tilknyttet. + empty: + title: Ingen initiativer fundet + hint: Prøv at justere dine filtre, eller opret et nyt initiativ. + section: + basics: Grundoplysninger + classification: Klassificering + endorsement: Vedtagelse + economy: Økonomi og tid + relations: Interessenter og kontakter + media: Filer og billeder + meta: Øvrigt + +contact: + name: Navn + email: E-mail + phone: Telefon + department: Afdeling + index: + title: Kontaktpersoner + subtitle: Personer der er tilknyttet initiativer. + new: + title: Ny kontaktperson + edit: + title: Rediger kontaktperson + empty: + title: Ingen kontaktpersoner endnu + hint: Opret en kontaktperson, eller tilføj en direkte på et initiativ. + +user: + email: E-mail + name: Navn + roles: Roller + password: Adgangskode + email_duplicate: Der findes allerede en bruger med denne e-mail. + password_required: Angiv en adgangskode. + password_too_short: Adgangskoden skal være mindst 8 tegn. + password_help: Mindst 8 tegn. Lad feltet stå tomt for at beholde den nuværende adgangskode. + +role: + user: Bruger + admin: Administrator + +admin: + users: + title: Brugere + subtitle: Administrér hvem der har adgang til platformen. + new: Ny bruger + edit: Rediger bruger + +login: + title: Log ind + email: E-mail + password: Adgangskode + submit: Log ind + error: Forkert e-mail eller adgangskode. + +flash: + initiative: + created: Initiativet blev oprettet. + updated: Initiativet blev opdateret. + deleted: Initiativet blev slettet. + contact: + created: Kontaktpersonen blev oprettet. + updated: Kontaktpersonen blev opdateret. + deleted: Kontaktpersonen blev slettet. + user: + created: Brugeren blev oprettet. + updated: Brugeren blev opdateret. + deleted: Brugeren blev slettet. + cannot_delete_self: Du kan ikke slette din egen bruger. + +enum: + status: + idea: Idé + planned: Planlagt + active: Igangværende + on_hold: Sat i bero + completed: Afsluttet + cancelled: Annulleret + category: + climate: Klima og miljø + mobility: Mobilitet + welfare: Velfærd + culture: Kultur og fritid + education: Uddannelse + business: Erhverv + digitalisation: Digitalisering + urban_development: Byudvikling + initiative_type: + project: Projekt + programme: Program + policy: Politik + pilot: Pilot + operation: Drift + organizational_anchoring: + mayors_department: Borgmesterens Afdeling + technical_and_environment: Teknik og Miljø + culture_and_citizens: Kultur og Borgerservice + social_and_employment: Sociale Forhold og Beskæftigelse + children_and_youth: Børn og Unge + health_and_care: Sundhed og Omsorg + endorsement_author: + city_council: Byrådet + magistrate: Magistraten + committee: Udvalg + department: Afdeling + funding: + municipal_budget: Kommunalt budget + state_grant: Statsligt tilskud + eu_funds: EU-midler + foundation: Fond + external_partner: Ekstern partner + vocabulary: + tag: Tag + stakeholder: Interessent + strategy: Strategi diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml new file mode 100644 index 0000000..4c40be1 --- /dev/null +++ b/translations/messages.en.yaml @@ -0,0 +1,223 @@ +app: + name: Project database + +nav: + dashboard: Overview + initiatives: Initiatives + contacts: Contacts + users: Users + admin: Administration + language: Language + signed_in_as: Signed in as + logout: Sign out + +action: + new: New + edit: Edit + delete: Delete + save: Save + cancel: Cancel + create: Create + back: Back + view: View + add: Add + filter: Filter + reset: Reset + export: Export CSV + add_link: Add link + add_contact: Add contact + add_image: Add image + add_attachment: Add file + remove: Remove + confirm_delete: Are you sure you want to delete this? + +common: + yes: Yes + no: No + none: None + actions: Actions + created: Created + updated: Updated + optional: optional + +form: + choose: Choose … + terms: + invalid: Invalid values. + +filter: + search: Search + search_placeholder: Search title, description, author … + all: All + yes: "Yes" + no: "No" + budget_min: Budget from + budget_max: Budget to + +dashboard: + title: Overview + subtitle: The status of every initiative in the database. + total: Total initiatives + by_status: By status + recent: Recently created + no_status: No status + empty: No initiatives have been created yet. + +initiative: + title: Title + category: Category + description: Short description + strategies: Strategies and plans + initiative_type: Type of initiative + status: Status + status_additional: Status – additional text + organizational_anchoring: Organisational anchoring + endorsement: Has the initiative been endorsed + endorsement_author: Where it was endorsed + budget: Budget (DKK) + funding: Funding sources + stakeholders: Stakeholders and partners + tags: Tags + time_period_start: Time period – start + time_period_end: Time period – end + time_period: Time period + links: Relevant links + contacts: Contacts + new_contacts: Add new contacts + author: Filled out by + terms_help: Separate multiple values with commas. + images: Images + attachments: Files + image_file: Image file + image_alt: Alternative text + attachment_file: File + attachment_invalid_type: "The file type is not allowed (allowed: pdf, doc, docx, xls, xlsx)." + index: + title: Initiatives + subtitle: Search, filter and create initiatives. + new: + title: New initiative + edit: + title: Edit initiative + show: + details: Details + contacts: Contacts + classification: Classification + media: Files and images + no_contacts: No contacts attached. + empty: + title: No initiatives found + hint: Try adjusting your filters, or create a new initiative. + section: + basics: Basics + classification: Classification + endorsement: Endorsement + economy: Economy and time + relations: Stakeholders and contacts + media: Files and images + meta: Other + +contact: + name: Name + email: Email + phone: Phone + department: Department + index: + title: Contacts + subtitle: People attached to initiatives. + new: + title: New contact + edit: + title: Edit contact + empty: + title: No contacts yet + hint: Create a contact, or add one directly on an initiative. + +user: + email: Email + name: Name + roles: Roles + password: Password + email_duplicate: A user with this email already exists. + password_required: Please provide a password. + password_too_short: The password must be at least 8 characters. + password_help: At least 8 characters. Leave blank to keep the current password. + +role: + user: User + admin: Administrator + +admin: + users: + title: Users + subtitle: Manage who can access the platform. + new: New user + edit: Edit user + +login: + title: Sign in + email: Email + password: Password + submit: Sign in + error: Invalid email or password. + +flash: + initiative: + created: The initiative was created. + updated: The initiative was updated. + deleted: The initiative was deleted. + contact: + created: The contact was created. + updated: The contact was updated. + deleted: The contact was deleted. + user: + created: The user was created. + updated: The user was updated. + deleted: The user was deleted. + cannot_delete_self: You cannot delete your own account. + +enum: + status: + idea: Idea + planned: Planned + active: Active + on_hold: On hold + completed: Completed + cancelled: Cancelled + category: + climate: Climate and environment + mobility: Mobility + welfare: Welfare + culture: Culture and leisure + education: Education + business: Business + digitalisation: Digitalisation + urban_development: Urban development + initiative_type: + project: Project + programme: Programme + policy: Policy + pilot: Pilot + operation: Operation + organizational_anchoring: + mayors_department: Mayor's Department + technical_and_environment: Technical Services and Environment + culture_and_citizens: Culture and Citizens' Services + social_and_employment: Social Affairs and Employment + children_and_youth: Children and Young People + health_and_care: Health and Care + endorsement_author: + city_council: City Council + magistrate: The Magistrate + committee: Committee + department: Department + funding: + municipal_budget: Municipal budget + state_grant: State grant + eu_funds: EU funds + foundation: Foundation + external_partner: External partner + vocabulary: + tag: Tag + stakeholder: Stakeholder + strategy: Strategy