From 62e761b1bd266201e3c33486d496e7455fee71b3 Mon Sep 17 00:00:00 2001 From: "ionut.branzea" Date: Thu, 28 May 2026 15:56:39 +0300 Subject: [PATCH 01/10] chore: bootstrap symfony api tooling --- .dockerignore | 7 + .env | 3 + .gitignore | 6 + .php-cs-fixer.dist.php | 12 + Dockerfile | 15 + Makefile | 32 + README.md | 27 +- bin/console | 15 + composer.json | 48 + composer.lock | 6483 ++++++++++++++++++++++++++++++++ config/bundles.php | 6 + config/packages/framework.yaml | 5 + config/packages/monolog.yaml | 6 + config/routes.yaml | 1 + config/services.yaml | 11 + docker-compose.yml | 9 + phpstan.neon | 6 + phpunit.xml.dist | 18 + public/index.php | 11 + src/Kernel.php | 13 + tests/SmokeTest.php | 15 + 21 files changed, 6746 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 Dockerfile create mode 100644 Makefile create mode 100755 bin/console create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/bundles.php create mode 100644 config/packages/framework.yaml create mode 100644 config/packages/monolog.yaml create mode 100644 config/routes.yaml create mode 100644 config/services.yaml create mode 100644 docker-compose.yml create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 public/index.php create mode 100644 src/Kernel.php create mode 100644 tests/SmokeTest.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0978c5c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +vendor/ +var/ +.git/ +.env.local +tmp/ +.php-cs-fixer.cache +.phpunit.cache diff --git a/.env b/.env new file mode 100644 index 0000000..4589326 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +APP_ENV=dev +APP_DEBUG=1 +APP_SECRET=change_me_in_env_local diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9c329b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +/var/ +/.env.local +/tmp/ +/.php-cs-fixer.cache +/.phpunit.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..4008445 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,12 @@ +in([__DIR__ . '/src', __DIR__ . '/tests']); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@Symfony' => true, + ]) + ->setFinder($finder); diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed14b57 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM php:8.3-cli-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + unzip \ + libcurl4-openssl-dev \ + && docker-php-ext-install curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +CMD ["sleep", "infinity"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a4d2e06 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +DOCKER_COMPOSE ?= docker compose +DOCKER_USER ?= $(shell id -u):$(shell id -g) +export DOCKER_USER + +PHP = $(DOCKER_COMPOSE) exec app php +COMPOSER = $(DOCKER_COMPOSE) exec app composer + +.PHONY: up down install shell test stan cs-check cs-fix + +up: + $(DOCKER_COMPOSE) up -d --build + +down: + $(DOCKER_COMPOSE) down + +install: + $(COMPOSER) install + +shell: + $(DOCKER_COMPOSE) exec app sh + +test: + $(PHP) vendor/bin/phpunit + +stan: + $(PHP) vendor/bin/phpstan analyse + +cs-check: + $(PHP) vendor/bin/php-cs-fixer check --diff + +cs-fix: + $(PHP) vendor/bin/php-cs-fixer fix diff --git a/README.md b/README.md index 83ab0f5..0b42177 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,31 @@ Response: PHP 8.3 · Symfony 7 · Docker -## Setup +## Running + +> Requirements: Docker and Docker Compose v2. No PHP needed on the host. ```bash -docker compose up -d +git clone coffee-shops-finder +cd coffee-shops-finder + +make up # build image and start the container +make install # install PHP dependencies via Composer +make test # run PHPUnit test suite +make stan # run PHPStan static analysis +make cs-check # check code style (PHP CS Fixer) +make cs-fix # auto-fix code style violations +make shell # open a shell inside the container +make down # stop and remove the container ``` -More setup and usage details will follow as the implementation progresses. +### Fallback (local PHP, no Docker) + +If you have PHP 8.3+ and Composer installed locally: + +```bash +composer install +./vendor/bin/phpunit +./vendor/bin/phpstan analyse +./vendor/bin/php-cs-fixer check --diff +``` diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..777c0ac --- /dev/null +++ b/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env php +=8.3", + "ext-curl": "*", + "monolog/monolog": "^3.7", + "symfony/dotenv": "^7.2", + "symfony/framework-bundle": "^7.2", + "symfony/http-client": "^7.2", + "symfony/monolog-bundle": "^3.10", + "symfony/runtime": "^7.2", + "symfony/yaml": "^7.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.0" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true, + "allow-plugins": { + "symfony/runtime": true + } + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "7.*" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..00bc934 --- /dev/null +++ b/composer.lock @@ -0,0 +1,6483 @@ +{ + "_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": "ae1e1074cdcfa60afb5de4dd4aeea7c7", + "packages": [ + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+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/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/cache", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/4c09e18a92cce126cc0d1155825279fca8cd0673", + "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "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": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^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/v7.4.13" + }, + "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-24T08:43:14+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/config", + "version": "v7.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^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/v7.4.10" + }, + "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-03T14:20:49+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f299e20ce983be6c0744952533c6dfeaaa1448e2", + "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^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/v7.4.13" + }, + "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-20T14:07:29+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/dotenv", + "version": "v7.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "82e9b1355c68ef7b96397dbd34cc75a92eebae7c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/82e9b1355c68ef7b96397dbd34cc75a92eebae7c", + "reference": "82e9b1355c68ef7b96397dbd34cc75a92eebae7c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/process": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^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/v7.4.11" + }, + "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-11T13:02:51+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^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/v7.4.8" + }, + "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-24T13:12:05+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.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": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^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/v7.4.9" + }, + "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-18T13:18:21+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": "v7.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^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/v7.4.11" + }, + "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-11T16:38:44+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e0be088d22278583a82da281886e8c3592fbf149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^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/v7.4.8" + }, + "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-24T13:12:05+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "8be39c7bf9e6f58fe49c07927572a9df7c961c95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/8be39c7bf9e6f58fe49c07927572a9df7c961c95", + "reference": "8be39c7bf9e6f58fe49c07927572a9df7c961c95", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/cache": "^6.4.12|^7.0|^8.0", + "symfony/config": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^7.4.4|^8.0.4", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.3|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^7.1|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/routing": "^7.4|^8.0" + }, + "conflict": { + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/asset": "<6.4", + "symfony/asset-mapper": "<6.4", + "symfony/clock": "<6.4", + "symfony/console": "<6.4", + "symfony/dom-crawler": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/form": "<7.4", + "symfony/http-client": "<6.4", + "symfony/lock": "<6.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<7.4", + "symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", + "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", + "symfony/security-core": "<6.4", + "symfony/security-csrf": "<7.2", + "symfony/serializer": "<7.2.5", + "symfony/stopwatch": "<6.4", + "symfony/translation": "<7.3", + "symfony/twig-bridge": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4", + "symfony/web-profiler-bundle": "<6.4", + "symfony/webhook": "<7.2", + "symfony/workflow": "<7.4" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/json-streamer": "^7.3|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", + "symfony/notifier": "^6.4|^7.0|^8.0", + "symfony/object-mapper": "^7.3|^8.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/scheduler": "^6.4.4|^7.0.4|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.0", + "symfony/semaphore": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.2.5|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^7.3|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/webhook": "^7.2|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.3|^8.0", + "twig/twig": "^3.12" + }, + "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/v7.4.13" + }, + "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-23T18:04:28+00:00" + }, + { + "name": "symfony/http-client", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "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": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^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/v7.4.13" + }, + "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-24T09:57:54+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": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "bc354f47c62301e990b7874fa662326368508e2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^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/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^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/v7.4.13" + }, + "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-24T11:20:33+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "9df847980c436451f4f51d1284491bb4356dd989" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", + "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "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/v7.4.13" + }, + "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-27T08:31:43+00:00" + }, + { + "name": "symfony/monolog-bridge", + "version": "v7.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bridge.git", + "reference": "20bb2345ac7a9dd57724b6b7ada92c6d7d67b4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/20bb2345ac7a9dd57724b6b7ada92c6d7d67b4b8", + "reference": "20bb2345ac7a9dd57724b6b7ada92c6d7d67b4b8", + "shasum": "" + }, + "require": { + "monolog/monolog": "^3", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/security-core": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Monolog\\": "" + }, + "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 Monolog with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.12" + }, + "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-20T07:20:23+00:00" + }, + { + "name": "symfony/monolog-bundle", + "version": "v3.11.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bundle.git", + "reference": "d87468010570b2ec766152184918ee8d267c7411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/d87468010570b2ec766152184918ee8d267c7411", + "reference": "d87468010570b2ec766152184918ee8d267c7411", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "monolog/monolog": "^1.25.1 || ^2.0 || ^3.0", + "php": ">=8.1", + "symfony/config": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/monolog-bridge": "^6.4 || ^7.0", + "symfony/polyfill-php84": "^1.30" + }, + "require-dev": { + "symfony/console": "^6.4 || ^7.0", + "symfony/phpunit-bridge": "^7.3.3", + "symfony/yaml": "^6.4 || ^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MonologBundle\\": "src" + } + }, + "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 MonologBundle", + "homepage": "https://symfony.com", + "keywords": [ + "log", + "logging" + ], + "support": { + "issues": "https://github.com/symfony/monolog-bundle/issues", + "source": "https://github.com/symfony/monolog-bundle/tree/v3.11.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-04-02T18:23:01+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.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-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "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.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-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "8339098cae28673c15cce00d80734af0453054e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", + "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\\Php83\\": "" + }, + "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.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/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-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "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\\Php84\\": "" + }, + "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.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/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-26T12:51:13+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/routing", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^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/v7.4.13" + }, + "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-24T11:20:33+00:00" + }, + { + "name": "symfony/runtime", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/runtime.git", + "reference": "1a24cf8aab3a9378117718b35525c4126ad3adec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/runtime/zipball/1a24cf8aab3a9378117718b35525c4126ad3adec", + "reference": "1a24cf8aab3a9378117718b35525c4126ad3adec", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": ">=8.2" + }, + "conflict": { + "symfony/dotenv": "<6.4" + }, + "require-dev": { + "composer/composer": "^2.6", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^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/v7.4.13" + }, + "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-23T18:04:28+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/var-dumper", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^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/v7.4.8" + }, + "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-30T13:44:50+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "22e03a49c95ef054a43601cd159b222bfab1c701" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/22e03a49c95ef054a43601cd159b222bfab1c701", + "reference": "22e03a49c95ef054a43601cd159b222bfab1c701", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^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": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.9" + }, + "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-18T13:18:21+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "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/v7.4.13" + }, + "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-25T06:06:12+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.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^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.3.2" + }, + "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-11-12T16:29:46+00:00" + }, + { + "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": "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": "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": "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": "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.2", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a28d88a5e172b27e78d0816992b15a9df3da20f1", + "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.1.1", + "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", + "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.33", + "symfony/polyfill-php80": "^1.33", + "symfony/polyfill-php81": "^1.33", + "symfony/polyfill-php84": "^1.33", + "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.8.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.33", + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.8" + }, + "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.2" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-05-15T09:20:44+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/phpstan", + "version": "2.2.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b4cd98348c809924f62bb9cc8c047f5e73bc9a58", + "reference": "b4cd98348c809924f62bb9cc8c047f5e73bc9a58", + "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-05-28T08:22:43+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "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": "11.0.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/11.0.12" + }, + "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": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-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/5.1.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/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "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": "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/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.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/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.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": "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/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "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.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-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/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "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/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+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": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.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/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.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 PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.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/6.3.3" + }, + "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-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.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/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "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" + }, + { + "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/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-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/7.2.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/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.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": "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/6.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/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.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" + } + ], + "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/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.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/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.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": "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/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.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/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "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" + }, + { + "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/6.0.3" + }, + "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": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-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/5.1.3" + }, + "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": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "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 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/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+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/console", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^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/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^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/v7.4.13" + }, + "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-24T08:56:14+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "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/v7.4.8" + }, + "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-24T13:12:05+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-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-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "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\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.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-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "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\\Php81\\": "" + }, + "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.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/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-26T12:45:58+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "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/v7.4.13" + }, + "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-23T16:05:06+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "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/v7.4.8" + }, + "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-24T13:12:05+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "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.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^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/v7.4.13" + }, + "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-23T15:23:29+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "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" + } + ], + "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/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.3", + "ext-curl": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 0000000..5b11b41 --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,6 @@ + ['all' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], +]; diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 0000000..0480d68 --- /dev/null +++ b/config/packages/framework.yaml @@ -0,0 +1,5 @@ +framework: + secret: '%env(APP_SECRET)%' + router: + utf8: true + http_method_override: false diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml new file mode 100644 index 0000000..42d6f2e --- /dev/null +++ b/config/packages/monolog.yaml @@ -0,0 +1,6 @@ +monolog: + handlers: + main: + type: stream + path: '%kernel.logs_dir%/%kernel.environment%.log' + level: debug diff --git a/config/routes.yaml b/config/routes.yaml new file mode 100644 index 0000000..0ce1e50 --- /dev/null +++ b/config/routes.yaml @@ -0,0 +1 @@ +# Routes will be added in subsequent tasks diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..059db81 --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,11 @@ +parameters: + +services: + _defaults: + autowire: true + autoconfigure: true + + App\: + resource: '../src/' + exclude: + - '../src/Kernel.php' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cc0fc68 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + app: + build: . + user: "${DOCKER_USER:-1000:1000}" + environment: + HOME: /tmp + volumes: + - .:/app + working_dir: /app diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..5f1c341 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 8 + phpVersion: 80300 + paths: + - src + - tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..d66097a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + tests + + + + + src + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..8e1a40d --- /dev/null +++ b/public/index.php @@ -0,0 +1,11 @@ + Date: Thu, 28 May 2026 20:55:42 +0300 Subject: [PATCH 02/10] feat: add generic location domain model --- src/Domain/Location/Coordinates.php | 17 +++ .../Location/DistanceCalculatorInterface.php | 10 ++ .../Location/EuclideanDistanceCalculator.php | 16 +++ src/Domain/Location/LocationWithDistance.php | 17 +++ src/Domain/Location/NamedLocation.php | 17 +++ .../Location/NearestLocationsFinder.php | 66 +++++++++ .../Unit/Domain/Location/CoordinatesTest.php | 46 +++++++ .../EuclideanDistanceCalculatorTest.php | 63 +++++++++ .../Location/LocationWithDistanceTest.php | 53 ++++++++ .../Domain/Location/NamedLocationTest.php | 48 +++++++ .../Location/NearestLocationsFinderTest.php | 128 ++++++++++++++++++ 11 files changed, 481 insertions(+) create mode 100644 src/Domain/Location/Coordinates.php create mode 100644 src/Domain/Location/DistanceCalculatorInterface.php create mode 100644 src/Domain/Location/EuclideanDistanceCalculator.php create mode 100644 src/Domain/Location/LocationWithDistance.php create mode 100644 src/Domain/Location/NamedLocation.php create mode 100644 src/Domain/Location/NearestLocationsFinder.php create mode 100644 tests/Unit/Domain/Location/CoordinatesTest.php create mode 100644 tests/Unit/Domain/Location/EuclideanDistanceCalculatorTest.php create mode 100644 tests/Unit/Domain/Location/LocationWithDistanceTest.php create mode 100644 tests/Unit/Domain/Location/NamedLocationTest.php create mode 100644 tests/Unit/Domain/Location/NearestLocationsFinderTest.php diff --git a/src/Domain/Location/Coordinates.php b/src/Domain/Location/Coordinates.php new file mode 100644 index 0000000..a2403d2 --- /dev/null +++ b/src/Domain/Location/Coordinates.php @@ -0,0 +1,17 @@ +x - $to->x; + $dy = $from->y - $to->y; + + return sqrt($dx * $dx + $dy * $dy); + } +} diff --git a/src/Domain/Location/LocationWithDistance.php b/src/Domain/Location/LocationWithDistance.php new file mode 100644 index 0000000..e13e7a4 --- /dev/null +++ b/src/Domain/Location/LocationWithDistance.php @@ -0,0 +1,17 @@ + $locations + * + * @return list + */ + public function findNearest(Coordinates $origin, iterable $locations, int $limit = 3): array + { + if ($limit < 1) { + throw new \InvalidArgumentException('Limit must be at least 1.'); + } + + /** @var list $nearest */ + $nearest = []; + + foreach ($locations as $location) { + $distance = $this->distanceCalculator->calculate($origin, $location->coordinates); + + $size = \count($nearest); + if ($size === $limit && $distance >= $nearest[$size - 1]->distance) { + continue; + } + + $nearest = $this->insertCandidate($nearest, new LocationWithDistance($location, $distance), $limit); + } + + return $nearest; + } + + /** + * @param list $nearest + * + * @return list + */ + private function insertCandidate(array $nearest, LocationWithDistance $candidate, int $limit): array + { + $insertAt = \count($nearest); + + foreach ($nearest as $index => $current) { + if ($candidate->distance < $current->distance) { + $insertAt = $index; + break; + } + } + + array_splice($nearest, $insertAt, 0, [$candidate]); + + if (\count($nearest) > $limit) { + array_pop($nearest); + } + + return $nearest; + } +} diff --git a/tests/Unit/Domain/Location/CoordinatesTest.php b/tests/Unit/Domain/Location/CoordinatesTest.php new file mode 100644 index 0000000..fba7716 --- /dev/null +++ b/tests/Unit/Domain/Location/CoordinatesTest.php @@ -0,0 +1,46 @@ +x); + self::assertSame(-122.4, $coordinates->y); + } + + #[DataProvider('nonFiniteValues')] + public function testRejectsNonFiniteX(float $invalidX): void + { + $this->expectException(\InvalidArgumentException::class); + + new Coordinates($invalidX, 0.0); + } + + #[DataProvider('nonFiniteValues')] + public function testRejectsNonFiniteY(float $invalidY): void + { + $this->expectException(\InvalidArgumentException::class); + + new Coordinates(0.0, $invalidY); + } + + /** + * @return iterable + */ + public static function nonFiniteValues(): iterable + { + yield 'NAN' => [\NAN]; + yield 'INF' => [\INF]; + yield '-INF' => [-\INF]; + } +} diff --git a/tests/Unit/Domain/Location/EuclideanDistanceCalculatorTest.php b/tests/Unit/Domain/Location/EuclideanDistanceCalculatorTest.php new file mode 100644 index 0000000..a14affb --- /dev/null +++ b/tests/Unit/Domain/Location/EuclideanDistanceCalculatorTest.php @@ -0,0 +1,63 @@ +calculate( + new Coordinates(3.0, 4.0), + new Coordinates(3.0, 4.0), + )); + } + + #[DataProvider('distancePairs')] + public function testCalculatesEuclideanDistance( + float $x1, + float $y1, + float $x2, + float $y2, + float $expected, + ): void { + $calculator = new EuclideanDistanceCalculator(); + + self::assertEqualsWithDelta( + $expected, + $calculator->calculate(new Coordinates($x1, $y1), new Coordinates($x2, $y2)), + 1e-9, + ); + } + + public function testIsSymmetric(): void + { + $calculator = new EuclideanDistanceCalculator(); + $a = new Coordinates(1.5, -2.5); + $b = new Coordinates(4.0, 7.25); + + self::assertSame( + $calculator->calculate($a, $b), + $calculator->calculate($b, $a), + ); + } + + /** + * @return iterable + */ + public static function distancePairs(): iterable + { + yield '3-4-5 triangle' => [0.0, 0.0, 3.0, 4.0, 5.0]; + yield 'horizontal segment' => [0.0, 0.0, 5.0, 0.0, 5.0]; + yield 'vertical segment' => [0.0, 0.0, 0.0, 7.0, 7.0]; + yield 'negative offset' => [-1.0, -1.0, 2.0, 3.0, 5.0]; + } +} diff --git a/tests/Unit/Domain/Location/LocationWithDistanceTest.php b/tests/Unit/Domain/Location/LocationWithDistanceTest.php new file mode 100644 index 0000000..99a9c0c --- /dev/null +++ b/tests/Unit/Domain/Location/LocationWithDistanceTest.php @@ -0,0 +1,53 @@ +location); + self::assertSame(4.5, $result->distance); + } + + public function testAllowsZeroDistance(): void + { + $named = new NamedLocation('Origin', new Coordinates(0.0, 0.0)); + $result = new LocationWithDistance($named, 0.0); + + self::assertSame(0.0, $result->distance); + } + + #[DataProvider('invalidDistances')] + public function testRejectsInvalidDistance(float $invalid): void + { + $this->expectException(\InvalidArgumentException::class); + + new LocationWithDistance( + new NamedLocation('Some place', new Coordinates(0.0, 0.0)), + $invalid, + ); + } + + /** + * @return iterable + */ + public static function invalidDistances(): iterable + { + yield 'negative' => [-0.1]; + yield 'NAN' => [\NAN]; + yield 'INF' => [\INF]; + yield '-INF' => [-\INF]; + } +} diff --git a/tests/Unit/Domain/Location/NamedLocationTest.php b/tests/Unit/Domain/Location/NamedLocationTest.php new file mode 100644 index 0000000..433fcd9 --- /dev/null +++ b/tests/Unit/Domain/Location/NamedLocationTest.php @@ -0,0 +1,48 @@ +name); + self::assertSame($coordinates, $location->coordinates); + } + + public function testItStoresOriginalNameUnchanged(): void + { + $location = new NamedLocation(' Central Cafe ', new Coordinates(10.0, 20.0)); + + self::assertSame(' Central Cafe ', $location->name); + } + + #[DataProvider('emptyNames')] + public function testRejectsEmptyOrWhitespaceName(string $invalidName): void + { + $this->expectException(\InvalidArgumentException::class); + + new NamedLocation($invalidName, new Coordinates(0.0, 0.0)); + } + + /** + * @return iterable + */ + public static function emptyNames(): iterable + { + yield 'empty string' => ['']; + yield 'spaces only' => [' ']; + yield 'tabs only' => ["\t\t"]; + yield 'newline only' => ["\n"]; + } +} diff --git a/tests/Unit/Domain/Location/NearestLocationsFinderTest.php b/tests/Unit/Domain/Location/NearestLocationsFinderTest.php new file mode 100644 index 0000000..ed2245e --- /dev/null +++ b/tests/Unit/Domain/Location/NearestLocationsFinderTest.php @@ -0,0 +1,128 @@ +findNearest(new Coordinates(0.0, 0.0), [])); + } + + public function testReturnsFewerThanLimitWhenIterableHasFewer(): void + { + $finder = new NearestLocationsFinder(new EuclideanDistanceCalculator()); + + $result = $finder->findNearest(new Coordinates(0.0, 0.0), [ + self::location('A', 1.0, 0.0), + self::location('B', 0.0, 2.0), + ], 3); + + self::assertSame(['A', 'B'], self::names($result)); + } + + public function testReturnsTopThreeOrderedByAscendingDistance(): void + { + $finder = new NearestLocationsFinder(new EuclideanDistanceCalculator()); + + $result = $finder->findNearest(new Coordinates(0.0, 0.0), [ + self::location('far', 10.0, 10.0), + self::location('closest', 0.1, 0.0), + self::location('mid', 1.0, 1.0), + self::location('second', 0.5, 0.5), + self::location('beyond', 100.0, 100.0), + ], 3); + + self::assertSame(['closest', 'second', 'mid'], self::names($result)); + } + + public function testRespectsLimitOfOne(): void + { + $finder = new NearestLocationsFinder(new EuclideanDistanceCalculator()); + + $result = $finder->findNearest(new Coordinates(0.0, 0.0), [ + self::location('far', 5.0, 5.0), + self::location('near', 1.0, 0.0), + ], 1); + + self::assertSame(['near'], self::names($result)); + } + + #[DataProvider('invalidLimits')] + public function testRejectsZeroOrNegativeLimit(int $invalidLimit): void + { + $finder = new NearestLocationsFinder(new EuclideanDistanceCalculator()); + + $this->expectException(\InvalidArgumentException::class); + + $finder->findNearest(new Coordinates(0.0, 0.0), [], $invalidLimit); + } + + public function testConsumesGeneratorForwardOnly(): void + { + $finder = new NearestLocationsFinder(new EuclideanDistanceCalculator()); + + $generator = (static function (): \Generator { + yield self::location('A', 1.0, 0.0); + yield self::location('B', 0.0, 0.5); + })(); + + $result = $finder->findNearest(new Coordinates(0.0, 0.0), $generator, 3); + + self::assertSame(['B', 'A'], self::names($result)); + } + + public function testKeepsFirstInsertedOnTies(): void + { + $finder = new NearestLocationsFinder(new EuclideanDistanceCalculator()); + + $result = $finder->findNearest(new Coordinates(0.0, 0.0), [ + self::location('First', 1.0, 0.0), + self::location('Second', 0.0, 1.0), + self::location('Third', -1.0, 0.0), + self::location('Fourth', 0.0, -1.0), + ], 3); + + self::assertSame(['First', 'Second', 'Third'], self::names($result)); + } + + /** + * @return iterable + */ + public static function invalidLimits(): iterable + { + yield 'zero' => [0]; + yield 'negative' => [-1]; + yield 'large negative' => [-100]; + } + + private static function location(string $name, float $x, float $y): NamedLocation + { + return new NamedLocation($name, new Coordinates($x, $y)); + } + + /** + * @param list $locations + * + * @return list + */ + private static function names(array $locations): array + { + return array_map( + static fn (LocationWithDistance $result): string => $result->location->name, + $locations, + ); + } +} From 81f67e62d05db1f940431018f4497bac8a7926a8 Mon Sep 17 00:00:00 2001 From: "ionut.branzea" Date: Fri, 29 May 2026 06:32:36 +0300 Subject: [PATCH 03/10] feat: add csv coffee shop infrastructure --- .env | 4 + Makefile | 2 +- .../CoffeeShopProviderInterface.php | 15 ++ src/Exception/CsvFetchException.php | 9 ++ src/Exception/NoValidLocationsException.php | 9 ++ .../CoffeeShop/CsvFileCache.php | 74 +++++++++ .../CoffeeShop/CsvLocationParser.php | 144 +++++++++++++++++ .../CoffeeShop/HttpCsvFetcher.php | 47 ++++++ .../RemoteCsvCoffeeShopProvider.php | 48 ++++++ tests/Fixtures/Csv/empty.csv | 0 tests/Fixtures/Csv/header_only.csv | 1 + tests/Fixtures/Csv/invalid_header.csv | 2 + tests/Fixtures/Csv/malformed_rows.csv | 9 ++ tests/Fixtures/Csv/valid.csv | 4 + tests/Fixtures/Csv/with_whitespace.csv | 2 + tests/Fixtures/Csv/zero_valid.csv | 3 + .../CoffeeShop/CsvFileCacheTest.php | 137 +++++++++++++++++ .../CoffeeShop/CsvLocationParserTest.php | 118 ++++++++++++++ .../CoffeeShop/HttpCsvFetcherTest.php | 81 ++++++++++ .../RemoteCsvCoffeeShopProviderTest.php | 145 ++++++++++++++++++ 20 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 src/Application/CoffeeShop/CoffeeShopProviderInterface.php create mode 100644 src/Exception/CsvFetchException.php create mode 100644 src/Exception/NoValidLocationsException.php create mode 100644 src/Infrastructure/CoffeeShop/CsvFileCache.php create mode 100644 src/Infrastructure/CoffeeShop/CsvLocationParser.php create mode 100644 src/Infrastructure/CoffeeShop/HttpCsvFetcher.php create mode 100644 src/Infrastructure/CoffeeShop/RemoteCsvCoffeeShopProvider.php create mode 100644 tests/Fixtures/Csv/empty.csv create mode 100644 tests/Fixtures/Csv/header_only.csv create mode 100644 tests/Fixtures/Csv/invalid_header.csv create mode 100644 tests/Fixtures/Csv/malformed_rows.csv create mode 100644 tests/Fixtures/Csv/valid.csv create mode 100644 tests/Fixtures/Csv/with_whitespace.csv create mode 100644 tests/Fixtures/Csv/zero_valid.csv create mode 100644 tests/Unit/Infrastructure/CoffeeShop/CsvFileCacheTest.php create mode 100644 tests/Unit/Infrastructure/CoffeeShop/CsvLocationParserTest.php create mode 100644 tests/Unit/Infrastructure/CoffeeShop/HttpCsvFetcherTest.php create mode 100644 tests/Unit/Infrastructure/CoffeeShop/RemoteCsvCoffeeShopProviderTest.php diff --git a/.env b/.env index 4589326..8f94adc 100644 --- a/.env +++ b/.env @@ -1,3 +1,7 @@ APP_ENV=dev APP_DEBUG=1 APP_SECRET=change_me_in_env_local + +CSV_FETCH_URL=https://static.reasig.ro/interview/coffee_shops_exerceise/coffee_shops.csv +CSV_FETCH_TIMEOUT_SECONDS=5 +CSV_CACHE_TTL_SECONDS=300 diff --git a/Makefile b/Makefile index a4d2e06..a80d4d5 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ test: $(PHP) vendor/bin/phpunit stan: - $(PHP) vendor/bin/phpstan analyse + $(PHP) vendor/bin/phpstan analyse --memory-limit=512M cs-check: $(PHP) vendor/bin/php-cs-fixer check --diff diff --git a/src/Application/CoffeeShop/CoffeeShopProviderInterface.php b/src/Application/CoffeeShop/CoffeeShopProviderInterface.php new file mode 100644 index 0000000..37769a1 --- /dev/null +++ b/src/Application/CoffeeShop/CoffeeShopProviderInterface.php @@ -0,0 +1,15 @@ + + */ + public function getAll(): iterable; +} diff --git a/src/Exception/CsvFetchException.php b/src/Exception/CsvFetchException.php new file mode 100644 index 0000000..cbe63e4 --- /dev/null +++ b/src/Exception/CsvFetchException.php @@ -0,0 +1,9 @@ +filePath) && is_readable($this->filePath); + } + + public function get(): string + { + $contents = @file_get_contents($this->filePath); + if (false === $contents) { + throw new \RuntimeException(sprintf('Cannot read cache file: %s', $this->filePath)); + } + + return $contents; + } + + public function set(string $contents): void + { + $directory = \dirname($this->filePath); + if (!is_dir($directory) && !@mkdir($directory, 0o775, true) && !is_dir($directory)) { + throw new \RuntimeException(sprintf('Cannot create cache directory: %s', $directory)); + } + + $temp = tempnam($directory, 'csv-cache-'); + if (false === $temp) { + throw new \RuntimeException(sprintf('Cannot create temp file in: %s', $directory)); + } + + if (false === file_put_contents($temp, $contents)) { + @unlink($temp); + throw new \RuntimeException('Cannot write cache contents.'); + } + + if (!@rename($temp, $this->filePath)) { + @unlink($temp); + throw new \RuntimeException('Cannot rename cache file into place.'); + } + } + + public function isStale(): bool + { + if (!$this->has()) { + return true; + } + + $mtime = @filemtime($this->filePath); + if (false === $mtime) { + return true; + } + + return (time() - $mtime) >= $this->ttlSeconds; + } + + public function getPath(): string + { + return $this->filePath; + } +} diff --git a/src/Infrastructure/CoffeeShop/CsvLocationParser.php b/src/Infrastructure/CoffeeShop/CsvLocationParser.php new file mode 100644 index 0000000..6ed768c --- /dev/null +++ b/src/Infrastructure/CoffeeShop/CsvLocationParser.php @@ -0,0 +1,144 @@ + */ + private const EXPECTED_HEADER = ['Name', 'X', 'Y']; + + public function __construct( + private readonly LoggerInterface $logger, + ) { + } + + /** + * @return iterable + */ + public function parse(string $filePath): iterable + { + $handle = @fopen($filePath, 'rb'); + if (false === $handle) { + throw new \RuntimeException(sprintf('Cannot open CSV file: %s', $filePath)); + } + + try { + $header = fgetcsv($handle); + if (false === $header || !$this->isValidHeader($header)) { + throw new NoValidLocationsException(sprintf('Invalid or missing CSV header in %s.', $filePath)); + } + + $validCount = 0; + $rowNumber = 1; + + while (false !== ($row = fgetcsv($handle))) { + ++$rowNumber; + + $named = $this->parseRow($row, $rowNumber); + if (null === $named) { + continue; + } + + ++$validCount; + yield $named; + } + + if (0 === $validCount) { + throw new NoValidLocationsException(sprintf('CSV file %s contains zero valid locations.', $filePath)); + } + } finally { + fclose($handle); + } + } + + /** + * @param array $header + */ + private function isValidHeader(array $header): bool + { + if (3 !== \count($header)) { + return false; + } + + $normalized = array_map( + static fn (?string $value): string => trim((string) $value), + $header, + ); + + return self::EXPECTED_HEADER === $normalized; + } + + /** + * @param array $row + */ + private function parseRow(array $row, int $rowNumber): ?NamedLocation + { + if (1 === \count($row) && '' === trim((string) ($row[0] ?? ''))) { + $this->logMalformedRow(self::REASON_BLANK_ROW, $rowNumber); + + return null; + } + + if (3 !== \count($row)) { + $this->logMalformedRow(self::REASON_WRONG_COLUMN_COUNT, $rowNumber, ['columns' => \count($row)]); + + return null; + } + + $name = trim((string) ($row[0] ?? '')); + $xRaw = trim((string) ($row[1] ?? '')); + $yRaw = trim((string) ($row[2] ?? '')); + + if ('' === $name) { + $this->logMalformedRow(self::REASON_EMPTY_NAME, $rowNumber); + + return null; + } + + if (!is_numeric($xRaw)) { + $this->logMalformedRow(self::REASON_NON_NUMERIC_X, $rowNumber, ['value' => $xRaw]); + + return null; + } + + if (!is_numeric($yRaw)) { + $this->logMalformedRow(self::REASON_NON_NUMERIC_Y, $rowNumber, ['value' => $yRaw]); + + return null; + } + + try { + return new NamedLocation($name, new Coordinates((float) $xRaw, (float) $yRaw)); + } catch (\InvalidArgumentException) { + $this->logMalformedRow(self::REASON_NON_FINITE_COORDINATES, $rowNumber, ['x' => $xRaw, 'y' => $yRaw]); + + return null; + } + } + + /** + * @param array $context + */ + private function logMalformedRow(string $reason, int $rowNumber, array $context = []): void + { + $this->logger->warning('Malformed CSV row', [ + 'reason' => $reason, + 'row' => $rowNumber, + ...$context, + ]); + } +} diff --git a/src/Infrastructure/CoffeeShop/HttpCsvFetcher.php b/src/Infrastructure/CoffeeShop/HttpCsvFetcher.php new file mode 100644 index 0000000..551a8cd --- /dev/null +++ b/src/Infrastructure/CoffeeShop/HttpCsvFetcher.php @@ -0,0 +1,47 @@ +httpClient->request('GET', $this->url, [ + 'timeout' => $this->timeoutSeconds, + ]); + + return $response->getContent(); + } catch (ExceptionInterface $e) { + $lastError = $e; + } + } + + throw new CsvFetchException(sprintf('Failed to fetch CSV from %s after %d attempts.', $this->url, $attempts), 0, $lastError); + } +} diff --git a/src/Infrastructure/CoffeeShop/RemoteCsvCoffeeShopProvider.php b/src/Infrastructure/CoffeeShop/RemoteCsvCoffeeShopProvider.php new file mode 100644 index 0000000..485e92e --- /dev/null +++ b/src/Infrastructure/CoffeeShop/RemoteCsvCoffeeShopProvider.php @@ -0,0 +1,48 @@ + + */ + public function getAll(): iterable + { + if ($this->cache->has() && !$this->cache->isStale()) { + return $this->parser->parse($this->cache->getPath()); + } + + try { + $csv = $this->fetcher->fetch(); + $this->cache->set($csv); + + return $this->parser->parse($this->cache->getPath()); + } catch (CsvFetchException $e) { + if ($this->cache->has()) { + $this->logger->warning('Remote CSV fetch failed; using stale cache.', [ + 'exception' => $e->getMessage(), + ]); + + return $this->parser->parse($this->cache->getPath()); + } + + throw $e; + } + } +} diff --git a/tests/Fixtures/Csv/empty.csv b/tests/Fixtures/Csv/empty.csv new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/Csv/header_only.csv b/tests/Fixtures/Csv/header_only.csv new file mode 100644 index 0000000..097e267 --- /dev/null +++ b/tests/Fixtures/Csv/header_only.csv @@ -0,0 +1 @@ +Name,X,Y diff --git a/tests/Fixtures/Csv/invalid_header.csv b/tests/Fixtures/Csv/invalid_header.csv new file mode 100644 index 0000000..ab1e294 --- /dev/null +++ b/tests/Fixtures/Csv/invalid_header.csv @@ -0,0 +1,2 @@ +name,latitude,longitude +1,2,3 diff --git a/tests/Fixtures/Csv/malformed_rows.csv b/tests/Fixtures/Csv/malformed_rows.csv new file mode 100644 index 0000000..eb95b4a --- /dev/null +++ b/tests/Fixtures/Csv/malformed_rows.csv @@ -0,0 +1,9 @@ +Name,X,Y +Valid First,1.0,2.0 + +,3,4 +Bad Coords,abc,def +Overflow X,1e500,5 +Bad Y Only,1.0,abc +Wrong Column Count,1 +Valid Last,10,20 diff --git a/tests/Fixtures/Csv/valid.csv b/tests/Fixtures/Csv/valid.csv new file mode 100644 index 0000000..55991fa --- /dev/null +++ b/tests/Fixtures/Csv/valid.csv @@ -0,0 +1,4 @@ +Name,X,Y +Starbucks Seattle,47.6,-122.4 +Starbucks SF,37.7,-122.4 +Coffee Origin,0,0 diff --git a/tests/Fixtures/Csv/with_whitespace.csv b/tests/Fixtures/Csv/with_whitespace.csv new file mode 100644 index 0000000..14422cc --- /dev/null +++ b/tests/Fixtures/Csv/with_whitespace.csv @@ -0,0 +1,2 @@ + Name , X , Y + Starbucks Padded , 47.6 , -122.4 diff --git a/tests/Fixtures/Csv/zero_valid.csv b/tests/Fixtures/Csv/zero_valid.csv new file mode 100644 index 0000000..d95b84a --- /dev/null +++ b/tests/Fixtures/Csv/zero_valid.csv @@ -0,0 +1,3 @@ +Name,X,Y +,1,2 +Bad,abc,def diff --git a/tests/Unit/Infrastructure/CoffeeShop/CsvFileCacheTest.php b/tests/Unit/Infrastructure/CoffeeShop/CsvFileCacheTest.php new file mode 100644 index 0000000..78f8437 --- /dev/null +++ b/tests/Unit/Infrastructure/CoffeeShop/CsvFileCacheTest.php @@ -0,0 +1,137 @@ +tmpDir = sys_get_temp_dir().'/coffee-shops-cache-'.uniqid('', true); + mkdir($this->tmpDir, 0o775, true); + $this->cachePath = $this->tmpDir.'/coffee_shops.csv'; + } + + protected function tearDown(): void + { + $this->removeDir($this->tmpDir); + } + + public function testHasReturnsFalseWhenFileMissing(): void + { + $cache = new CsvFileCache($this->cachePath, self::CACHE_TTL); + + self::assertFalse($cache->has()); + } + + public function testHasReturnsTrueAfterSet(): void + { + $cache = new CsvFileCache($this->cachePath, self::CACHE_TTL); + $cache->set('hello'); + + self::assertTrue($cache->has()); + } + + public function testGetReturnsContentsAfterSet(): void + { + $cache = new CsvFileCache($this->cachePath, self::CACHE_TTL); + $cache->set("Name,X,Y\nA,1,2"); + + self::assertSame("Name,X,Y\nA,1,2", $cache->get()); + } + + public function testSetOverwritesExistingContents(): void + { + $cache = new CsvFileCache($this->cachePath, self::CACHE_TTL); + $cache->set('first'); + $cache->set('second'); + + self::assertSame('second', $cache->get()); + } + + public function testIsStaleReturnsTrueWhenFileMissing(): void + { + $cache = new CsvFileCache($this->cachePath, self::CACHE_TTL); + + self::assertTrue($cache->isStale()); + } + + public function testIsStaleReturnsFalseImmediatelyAfterSet(): void + { + $cache = new CsvFileCache($this->cachePath, self::CACHE_TTL); + $cache->set('fresh'); + + self::assertFalse($cache->isStale()); + } + + public function testIsStaleReturnsTrueWhenOlderThanTtl(): void + { + $cache = new CsvFileCache($this->cachePath, self::CACHE_TTL); + $cache->set('old'); + touch($this->cachePath, time() - 120); + + self::assertTrue($cache->isStale()); + } + + public function testSetCreatesMissingDirectory(): void + { + $nestedPath = $this->tmpDir.'/nested/dir/cache.csv'; + $cache = new CsvFileCache($nestedPath, self::CACHE_TTL); + + $cache->set('data'); + + self::assertFileExists($nestedPath); + } + + public function testGetPathReturnsConfiguredPath(): void + { + $cache = new CsvFileCache($this->cachePath, self::CACHE_TTL); + + self::assertSame($this->cachePath, $cache->getPath()); + } + + public function testGetThrowsWhenFileMissing(): void + { + $cache = new CsvFileCache($this->cachePath, self::CACHE_TTL); + + $this->expectException(\RuntimeException::class); + + $cache->get(); + } + + private function removeDir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $entries = scandir($dir); + if (false === $entries) { + return; + } + + foreach ($entries as $entry) { + if ('.' === $entry || '..' === $entry) { + continue; + } + + $path = $dir.'/'.$entry; + if (is_dir($path)) { + $this->removeDir($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } +} diff --git a/tests/Unit/Infrastructure/CoffeeShop/CsvLocationParserTest.php b/tests/Unit/Infrastructure/CoffeeShop/CsvLocationParserTest.php new file mode 100644 index 0000000..35b82d7 --- /dev/null +++ b/tests/Unit/Infrastructure/CoffeeShop/CsvLocationParserTest.php @@ -0,0 +1,118 @@ +parse(self::FIXTURES.'/valid.csv'), false); + + self::assertSame(['Starbucks Seattle', 'Starbucks SF', 'Coffee Origin'], self::names($result)); + self::assertSame(47.6, $result[0]->coordinates->x); + self::assertSame(-122.4, $result[0]->coordinates->y); + } + + public function testTrimsAllFields(): void + { + $parser = new CsvLocationParser(new NullLogger()); + + $result = iterator_to_array($parser->parse(self::FIXTURES.'/with_whitespace.csv'), false); + + self::assertCount(1, $result); + self::assertSame('Starbucks Padded', $result[0]->name); + self::assertSame(47.6, $result[0]->coordinates->x); + self::assertSame(-122.4, $result[0]->coordinates->y); + } + + public function testSkipsMalformedRowsAndYieldsOnlyValid(): void + { + $parser = new CsvLocationParser(new NullLogger()); + + $result = iterator_to_array($parser->parse(self::FIXTURES.'/malformed_rows.csv'), false); + + self::assertSame(['Valid First', 'Valid Last'], self::names($result)); + } + + public function testLogsStructuredReasonCodesForMalformedRows(): void + { + $handler = new TestHandler(); + $parser = new CsvLocationParser(new Logger('test', [$handler])); + + iterator_to_array($parser->parse(self::FIXTURES.'/malformed_rows.csv'), false); + + $reasons = array_map( + static fn (LogRecord $record): string => (string) ($record->context['reason'] ?? ''), + $handler->getRecords(), + ); + + self::assertSame( + ['blank_row', 'empty_name', 'non_numeric_x', 'non_finite_coordinates', 'non_numeric_y', 'wrong_column_count'], + $reasons, + ); + } + + public function testThrowsOnEmptyFile(): void + { + $parser = new CsvLocationParser(new NullLogger()); + + $this->expectException(NoValidLocationsException::class); + + iterator_to_array($parser->parse(self::FIXTURES.'/empty.csv'), false); + } + + public function testThrowsOnInvalidHeader(): void + { + $parser = new CsvLocationParser(new NullLogger()); + + $this->expectException(NoValidLocationsException::class); + + iterator_to_array($parser->parse(self::FIXTURES.'/invalid_header.csv'), false); + } + + public function testThrowsOnHeaderOnly(): void + { + $parser = new CsvLocationParser(new NullLogger()); + + $this->expectException(NoValidLocationsException::class); + + iterator_to_array($parser->parse(self::FIXTURES.'/header_only.csv'), false); + } + + public function testThrowsOnZeroValidRows(): void + { + $parser = new CsvLocationParser(new NullLogger()); + + $this->expectException(NoValidLocationsException::class); + + iterator_to_array($parser->parse(self::FIXTURES.'/zero_valid.csv'), false); + } + + /** + * @param list $locations + * + * @return list + */ + private static function names(array $locations): array + { + return array_map( + static fn (NamedLocation $location): string => $location->name, + $locations, + ); + } +} diff --git a/tests/Unit/Infrastructure/CoffeeShop/HttpCsvFetcherTest.php b/tests/Unit/Infrastructure/CoffeeShop/HttpCsvFetcherTest.php new file mode 100644 index 0000000..9f46e4c --- /dev/null +++ b/tests/Unit/Infrastructure/CoffeeShop/HttpCsvFetcherTest.php @@ -0,0 +1,81 @@ +fetch()); + } + + public function testRetriesAfterFailureAndSucceedsOnSecondAttempt(): void + { + $client = new MockHttpClient([ + new MockResponse(self::FAILURE_BODY, ['http_code' => self::SERVER_ERROR_CODE]), + new MockResponse(self::CSV_CONTENT), + ]); + + $fetcher = new HttpCsvFetcher($client, self::CSV_URL, self::DEFAULT_TIMEOUT); + + self::assertSame(self::CSV_CONTENT, $fetcher->fetch()); + } + + public function testThrowsCsvFetchExceptionAfterAllAttemptsFail(): void + { + $client = new MockHttpClient([ + new MockResponse(self::FAILURE_BODY, ['http_code' => self::SERVER_ERROR_CODE]), + new MockResponse(self::FAILURE_BODY, ['http_code' => self::SERVER_ERROR_CODE]), + new MockResponse(self::FAILURE_BODY, ['http_code' => self::SERVER_ERROR_CODE]), + ]); + + $fetcher = new HttpCsvFetcher($client, self::CSV_URL, self::DEFAULT_TIMEOUT); + + $this->expectException(CsvFetchException::class); + + $fetcher->fetch(); + } + + public function testPassesTimeoutToHttpClient(): void + { + $timeoutSeconds = 7; + + /** @var list> $capturedOptions */ + $capturedOptions = []; + + $client = new MockHttpClient( + function (string $method, string $url, array $options) use (&$capturedOptions): MockResponse { + $capturedOptions[] = $options; + + return new MockResponse('ok'); + }, + ); + + $fetcher = new HttpCsvFetcher($client, self::CSV_URL, $timeoutSeconds); + $fetcher->fetch(); + + self::assertCount(1, $capturedOptions); + self::assertArrayHasKey('timeout', $capturedOptions[0]); + self::assertEqualsWithDelta($timeoutSeconds, $capturedOptions[0]['timeout'], 0.001); + } +} diff --git a/tests/Unit/Infrastructure/CoffeeShop/RemoteCsvCoffeeShopProviderTest.php b/tests/Unit/Infrastructure/CoffeeShop/RemoteCsvCoffeeShopProviderTest.php new file mode 100644 index 0000000..da1d8ce --- /dev/null +++ b/tests/Unit/Infrastructure/CoffeeShop/RemoteCsvCoffeeShopProviderTest.php @@ -0,0 +1,145 @@ +createMock(HttpCsvFetcher::class); + $cache = $this->createMock(CsvFileCache::class); + $parser = $this->createMock(CsvLocationParser::class); + + $cache->method('has')->willReturn(true); + $cache->method('isStale')->willReturn(false); + $cache->method('getPath')->willReturn('/tmp/fresh.csv'); + + $fetcher->expects(self::never())->method('fetch'); + $cache->expects(self::never())->method('set'); + $parser->expects(self::once())->method('parse')->with('/tmp/fresh.csv') + ->willReturn(self::oneLocation('Fresh')); + + $provider = new RemoteCsvCoffeeShopProvider($fetcher, $cache, $parser, new NullLogger()); + + self::assertSame(['Fresh'], self::namesFrom($provider->getAll())); + } + + public function testFetchesAndUpdatesCacheWhenStale(): void + { + $fetcher = $this->createMock(HttpCsvFetcher::class); + $cache = $this->createMock(CsvFileCache::class); + $parser = $this->createMock(CsvLocationParser::class); + + $cache->method('has')->willReturn(true); + $cache->method('isStale')->willReturn(true); + $cache->method('getPath')->willReturn('/tmp/refreshed.csv'); + + $fetcher->expects(self::once())->method('fetch')->willReturn(self::CSV_CONTENT); + $cache->expects(self::once())->method('set')->with(self::CSV_CONTENT); + $parser->expects(self::once())->method('parse')->with('/tmp/refreshed.csv') + ->willReturn(self::oneLocation('Refreshed')); + + $provider = new RemoteCsvCoffeeShopProvider($fetcher, $cache, $parser, new NullLogger()); + + self::assertSame(['Refreshed'], self::namesFrom($provider->getAll())); + } + + public function testFetchesWhenCacheMissing(): void + { + $fetcher = $this->createMock(HttpCsvFetcher::class); + $cache = $this->createMock(CsvFileCache::class); + $parser = $this->createMock(CsvLocationParser::class); + + $cache->method('has')->willReturn(false); + $cache->method('isStale')->willReturn(true); + $cache->method('getPath')->willReturn('/tmp/new.csv'); + + $fetcher->expects(self::once())->method('fetch')->willReturn(self::CSV_CONTENT); + $cache->expects(self::once())->method('set')->with(self::CSV_CONTENT); + $parser->expects(self::once())->method('parse')->with('/tmp/new.csv') + ->willReturn(self::oneLocation('New')); + + $provider = new RemoteCsvCoffeeShopProvider($fetcher, $cache, $parser, new NullLogger()); + + self::assertSame(['New'], self::namesFrom($provider->getAll())); + } + + public function testUsesStaleCacheAsFallbackWhenFetchFails(): void + { + $fetcher = $this->createMock(HttpCsvFetcher::class); + $cache = $this->createMock(CsvFileCache::class); + $parser = $this->createMock(CsvLocationParser::class); + $logger = $this->createMock(LoggerInterface::class); + + $cache->method('has')->willReturn(true); + $cache->method('isStale')->willReturn(true); + $cache->method('getPath')->willReturn('/tmp/stale.csv'); + + $fetcher->method('fetch')->willThrowException(new CsvFetchException('upstream down')); + $cache->expects(self::never())->method('set'); + $logger->expects(self::once())->method('warning'); + $parser->expects(self::once())->method('parse')->with('/tmp/stale.csv') + ->willReturn(self::oneLocation('Stale')); + + $provider = new RemoteCsvCoffeeShopProvider($fetcher, $cache, $parser, $logger); + + self::assertSame(['Stale'], self::namesFrom($provider->getAll())); + } + + public function testThrowsWhenFetchFailsAndNoCacheExists(): void + { + $fetcher = $this->createMock(HttpCsvFetcher::class); + $cache = $this->createMock(CsvFileCache::class); + $parser = $this->createMock(CsvLocationParser::class); + + $cache->method('has')->willReturn(false); + $cache->method('isStale')->willReturn(true); + + $fetcher->method('fetch')->willThrowException(new CsvFetchException('upstream down')); + $parser->expects(self::never())->method('parse'); + + $provider = new RemoteCsvCoffeeShopProvider($fetcher, $cache, $parser, new NullLogger()); + + $this->expectException(CsvFetchException::class); + + iterator_to_array($provider->getAll(), false); + } + + /** + * @return \Generator + */ + private static function oneLocation(string $name): \Generator + { + yield new NamedLocation($name, new Coordinates(0.0, 0.0)); + } + + /** + * @param iterable $locations + * + * @return list + */ + private static function namesFrom(iterable $locations): array + { + $names = []; + foreach ($locations as $location) { + $names[] = $location->name; + } + + return $names; + } +} From 00df9ea9540f1c34163d7330470ae275206f50eb Mon Sep 17 00:00:00 2001 From: "ionut.branzea" Date: Fri, 29 May 2026 09:21:14 +0300 Subject: [PATCH 04/10] feat: add coffee shop provider and handler --- .../FindNearestCoffeeShopsHandler.php | 47 +++++++ .../CoffeeShop/NearestCoffeeShop.php | 16 +++ .../FindNearestCoffeeShopsHandlerTest.php | 126 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 src/Application/CoffeeShop/FindNearestCoffeeShopsHandler.php create mode 100644 src/Application/CoffeeShop/NearestCoffeeShop.php create mode 100644 tests/Unit/Application/CoffeeShop/FindNearestCoffeeShopsHandlerTest.php diff --git a/src/Application/CoffeeShop/FindNearestCoffeeShopsHandler.php b/src/Application/CoffeeShop/FindNearestCoffeeShopsHandler.php new file mode 100644 index 0000000..acff2e8 --- /dev/null +++ b/src/Application/CoffeeShop/FindNearestCoffeeShopsHandler.php @@ -0,0 +1,47 @@ + + */ + public function handle(Coordinates $coordinates, int $limit = 3): array + { + $nearest = $this->nearestLocationsFinder->findNearest( + $coordinates, + $this->provider->getAll(), + $limit, + ); + + $shops = []; + foreach ($nearest as $locationWithDistance) { + $shops[] = $this->mapResult($locationWithDistance); + } + + return $shops; + } + + private function mapResult(LocationWithDistance $locationWithDistance): NearestCoffeeShop + { + return new NearestCoffeeShop( + $locationWithDistance->location->name, + $locationWithDistance->location->coordinates->x, + $locationWithDistance->location->coordinates->y, + $locationWithDistance->distance, + ); + } +} diff --git a/src/Application/CoffeeShop/NearestCoffeeShop.php b/src/Application/CoffeeShop/NearestCoffeeShop.php new file mode 100644 index 0000000..b9d6e53 --- /dev/null +++ b/src/Application/CoffeeShop/NearestCoffeeShop.php @@ -0,0 +1,16 @@ +handler([ + self::location('Far', 10.0, 10.0), + self::location('Closest', 0.1, 0.0), + self::location('Third', 1.0, 1.0), + self::location('Second', 0.5, 0.5), + self::location('Beyond', 100.0, 100.0), + ]); + + $result = $handler->handle(new Coordinates(0.0, 0.0)); + + self::assertSame(['Closest', 'Second', 'Third'], self::names($result)); + } + + public function testRespectsCustomLimit(): void + { + $handler = $this->handler([ + self::location('A', 1.0, 0.0), + self::location('B', 2.0, 0.0), + self::location('C', 3.0, 0.0), + ]); + + $result = $handler->handle(new Coordinates(0.0, 0.0), 2); + + self::assertSame(['A', 'B'], self::names($result)); + } + + public function testReturnsEmptyArrayWhenProviderHasNoLocations(): void + { + $handler = $this->handler([]); + + $result = $handler->handle(new Coordinates(0.0, 0.0)); + + self::assertSame([], $result); + } + + public function testWorksWhenProviderReturnsGenerator(): void + { + $generator = (static function (): \Generator { + yield self::location('A', 1.0, 0.0); + yield self::location('B', 0.0, 0.5); + })(); + + $handler = $this->handler($generator); + + $result = $handler->handle(new Coordinates(0.0, 0.0)); + + self::assertSame(['B', 'A'], self::names($result)); + } + + public function testDistanceIsRawAndNotRounded(): void + { + $handler = $this->handler([ + self::location('Point', 1.0, 1.0), + ]); + + $result = $handler->handle(new Coordinates(0.0, 0.0)); + + self::assertSame(sqrt(2.0), $result[0]->distance); + } + + public function testMapsNameCoordinatesAndDistance(): void + { + $handler = $this->handler([ + self::location('Origin Shop', 3.0, 4.0), + ]); + + $result = $handler->handle(new Coordinates(0.0, 0.0)); + + self::assertCount(1, $result); + self::assertSame('Origin Shop', $result[0]->name); + self::assertSame(3.0, $result[0]->x); + self::assertSame(4.0, $result[0]->y); + self::assertSame(5.0, $result[0]->distance); + } + + /** + * @param iterable $locations + */ + private function handler(iterable $locations): FindNearestCoffeeShopsHandler + { + $provider = $this->createMock(CoffeeShopProviderInterface::class); + $provider->expects($this->once())->method('getAll')->willReturn($locations); + + return new FindNearestCoffeeShopsHandler( + $provider, + new NearestLocationsFinder(new EuclideanDistanceCalculator()), + ); + } + + private static function location(string $name, float $x, float $y): NamedLocation + { + return new NamedLocation($name, new Coordinates($x, $y)); + } + + /** + * @param list $shops + * + * @return list + */ + private static function names(array $shops): array + { + return array_map( + static fn (NearestCoffeeShop $shop): string => $shop->name, + $shops, + ); + } +} From 6e259be84444bddb48dd14b20038866bb70de18a Mon Sep 17 00:00:00 2001 From: "ionut.branzea" Date: Sun, 31 May 2026 16:42:41 +0300 Subject: [PATCH 05/10] feat: expose nearest coffee shops endpoint --- .gitignore | 1 + Makefile | 5 +- config/packages/test/framework.yaml | 2 + config/routes.yaml | 5 +- config/services.yaml | 26 ++ docker-compose.yml | 2 + phpunit.xml.dist | 9 + .../NearestCoffeeShopsController.php | 78 ++++++ src/Exception/InvalidCoordinatesException.php | 9 + .../Subscriber/ApiExceptionSubscriber.php | 83 ++++++ .../CoffeeShop/CsvLocationParser.php | 27 +- .../NearestCoffeeShopsControllerTest.php | 236 ++++++++++++++++++ .../CoffeeShop/CsvLocationParserTest.php | 15 +- 13 files changed, 479 insertions(+), 19 deletions(-) create mode 100644 config/packages/test/framework.yaml create mode 100644 src/Controller/NearestCoffeeShopsController.php create mode 100644 src/Exception/InvalidCoordinatesException.php create mode 100644 src/Http/Subscriber/ApiExceptionSubscriber.php create mode 100644 tests/Functional/Controller/NearestCoffeeShopsControllerTest.php diff --git a/.gitignore b/.gitignore index d9c329b..8d73dc5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /tmp/ /.php-cs-fixer.cache /.phpunit.cache +/config/reference.php diff --git a/Makefile b/Makefile index a80d4d5..50bf651 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ export DOCKER_USER PHP = $(DOCKER_COMPOSE) exec app php COMPOSER = $(DOCKER_COMPOSE) exec app composer -.PHONY: up down install shell test stan cs-check cs-fix +.PHONY: up down install shell test stan cs-check cs-fix serve up: $(DOCKER_COMPOSE) up -d --build @@ -30,3 +30,6 @@ cs-check: cs-fix: $(PHP) vendor/bin/php-cs-fixer fix + +serve: + $(PHP) -S 0.0.0.0:8080 -t public/ diff --git a/config/packages/test/framework.yaml b/config/packages/test/framework.yaml new file mode 100644 index 0000000..f76cc2e --- /dev/null +++ b/config/packages/test/framework.yaml @@ -0,0 +1,2 @@ +framework: + test: true diff --git a/config/routes.yaml b/config/routes.yaml index 0ce1e50..975b71b 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -1 +1,4 @@ -# Routes will be added in subsequent tasks +nearest_coffee_shops: + path: /api/coffee-shops/nearest + controller: App\Controller\NearestCoffeeShopsController + methods: [GET] diff --git a/config/services.yaml b/config/services.yaml index 059db81..a73c1ed 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,3 +9,29 @@ services: resource: '../src/' exclude: - '../src/Kernel.php' + + App\Controller\: + resource: '../src/Controller/' + tags: ['controller.service_arguments'] + + App\Infrastructure\CoffeeShop\HttpCsvFetcher: + arguments: + $url: '%env(CSV_FETCH_URL)%' + $timeoutSeconds: '%env(int:CSV_FETCH_TIMEOUT_SECONDS)%' + + App\Infrastructure\CoffeeShop\CsvFileCache: + arguments: + $filePath: '%kernel.cache_dir%/coffee_shops.csv' + $ttlSeconds: '%env(int:CSV_CACHE_TTL_SECONDS)%' + + App\Application\CoffeeShop\CoffeeShopProviderInterface: + alias: App\Infrastructure\CoffeeShop\RemoteCsvCoffeeShopProvider + + App\Domain\Location\DistanceCalculatorInterface: + alias: App\Domain\Location\EuclideanDistanceCalculator + +when@test: + services: + App\Application\CoffeeShop\CoffeeShopProviderInterface: + alias: App\Infrastructure\CoffeeShop\RemoteCsvCoffeeShopProvider + public: true diff --git a/docker-compose.yml b/docker-compose.yml index cc0fc68..9fed14a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,8 @@ services: user: "${DOCKER_USER:-1000:1000}" environment: HOME: /tmp + ports: + - "8080:8080" volumes: - .:/app working_dir: /app diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d66097a..8dd80f2 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,6 +5,15 @@ colors="true" cacheDirectory=".phpunit.cache" > + + + + + + + + + tests diff --git a/src/Controller/NearestCoffeeShopsController.php b/src/Controller/NearestCoffeeShopsController.php new file mode 100644 index 0000000..0fd4d82 --- /dev/null +++ b/src/Controller/NearestCoffeeShopsController.php @@ -0,0 +1,78 @@ +queryCoordinate($request, 'x'); + $y = $this->queryCoordinate($request, 'y'); + + $shops = $this->handler->handle(new Coordinates($x, $y), self::RESULT_LIMIT); + + $response = []; + foreach ($shops as $shop) { + $response[] = $this->mapCoffeeShop($shop); + } + + return new JsonResponse($response); + } + + private function queryCoordinate(Request $request, string $name): float + { + $query = $request->query->all(); + + if (!\array_key_exists($name, $query)) { + throw new InvalidCoordinatesException(sprintf('Missing required query parameter "%s".', $name)); + } + + $value = $query[$name]; + + if (!is_scalar($value)) { + throw new InvalidCoordinatesException(sprintf('Query parameter "%s" must be a finite number.', $name)); + } + + $rawValue = trim((string) $value); + + if ('' === $rawValue || !is_numeric($rawValue)) { + throw new InvalidCoordinatesException(sprintf('Query parameter "%s" must be a finite number.', $name)); + } + + $float = (float) $rawValue; + + if (!is_finite($float)) { + throw new InvalidCoordinatesException(sprintf('Query parameter "%s" must be a finite number.', $name)); + } + + return $float; + } + + /** + * @return array{name: string, location: array{x: float, y: float}, distance: float} + */ + private function mapCoffeeShop(NearestCoffeeShop $shop): array + { + return [ + 'name' => $shop->name, + 'location' => ['x' => $shop->x, 'y' => $shop->y], + 'distance' => round($shop->distance, 4), + ]; + } +} diff --git a/src/Exception/InvalidCoordinatesException.php b/src/Exception/InvalidCoordinatesException.php new file mode 100644 index 0000000..e75c413 --- /dev/null +++ b/src/Exception/InvalidCoordinatesException.php @@ -0,0 +1,9 @@ + 'onKernelException', + ]; + } + + public function onKernelException(ExceptionEvent $event): void + { + $exception = $event->getThrowable(); + + if ($exception instanceof InvalidCoordinatesException) { + $event->setResponse($this->errorResponse( + 'INVALID_COORDINATES', + $exception->getMessage(), + Response::HTTP_BAD_REQUEST, + )); + + return; + } + + if ($exception instanceof CsvFetchException) { + $event->setResponse($this->errorResponse( + 'COFFEE_SHOPS_UNAVAILABLE', + 'Coffee shop data is temporarily unavailable. Please try again later.', + Response::HTTP_SERVICE_UNAVAILABLE, + )); + + return; + } + + if ($exception instanceof NoValidLocationsException) { + $event->setResponse($this->errorResponse( + 'NO_VALID_LOCATIONS', + 'No valid coffee shop locations are available.', + Response::HTTP_UNPROCESSABLE_ENTITY, + )); + + return; + } + + $this->logger->critical('Unhandled exception.', ['exception' => $exception]); + + $event->setResponse($this->errorResponse( + 'INTERNAL_ERROR', + 'An unexpected error occurred. Please try again later.', + Response::HTTP_INTERNAL_SERVER_ERROR, + )); + } + + private function errorResponse(string $code, string $message, int $status): JsonResponse + { + return new JsonResponse([ + 'error' => [ + 'code' => $code, + 'message' => $message, + ], + ], $status); + } +} diff --git a/src/Infrastructure/CoffeeShop/CsvLocationParser.php b/src/Infrastructure/CoffeeShop/CsvLocationParser.php index 6ed768c..e3bdb2b 100644 --- a/src/Infrastructure/CoffeeShop/CsvLocationParser.php +++ b/src/Infrastructure/CoffeeShop/CsvLocationParser.php @@ -19,7 +19,7 @@ class CsvLocationParser private const REASON_NON_FINITE_COORDINATES = 'non_finite_coordinates'; /** @var list */ - private const EXPECTED_HEADER = ['Name', 'X', 'Y']; + private const EXPECTED_HEADER = ['name', 'x', 'y']; public function __construct( private readonly LoggerInterface $logger, @@ -37,24 +37,25 @@ public function parse(string $filePath): iterable } try { - $header = fgetcsv($handle); - if (false === $header || !$this->isValidHeader($header)) { - throw new NoValidLocationsException(sprintf('Invalid or missing CSV header in %s.', $filePath)); - } - $validCount = 0; - $rowNumber = 1; + $rowNumber = 0; + + $row = fgetcsv($handle); + if (false !== $row && $this->isValidHeader($row)) { + ++$rowNumber; + $row = fgetcsv($handle); + } - while (false !== ($row = fgetcsv($handle))) { + while (false !== $row) { ++$rowNumber; $named = $this->parseRow($row, $rowNumber); - if (null === $named) { - continue; + if (null !== $named) { + ++$validCount; + yield $named; } - ++$validCount; - yield $named; + $row = fgetcsv($handle); } if (0 === $validCount) { @@ -75,7 +76,7 @@ private function isValidHeader(array $header): bool } $normalized = array_map( - static fn (?string $value): string => trim((string) $value), + static fn (?string $value): string => strtolower(trim((string) $value)), $header, ); diff --git a/tests/Functional/Controller/NearestCoffeeShopsControllerTest.php b/tests/Functional/Controller/NearestCoffeeShopsControllerTest.php new file mode 100644 index 0000000..1771c52 --- /dev/null +++ b/tests/Functional/Controller/NearestCoffeeShopsControllerTest.php @@ -0,0 +1,236 @@ +replaceProviderLocations([ + self::location('Closest', 0.1, 0.5), + self::location('Second', 0.5, 0.5), + self::location('Third', 1.0, 1.0), + self::location('Far', 10.0, 10.0), + self::location('Beyond', 100.0, 100.0), + ]); + + $response = $kernel->handle(Request::create(self::ENDPOINT.'?x=0.0&y=0.0')); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + $data = self::json($response); + + self::assertCount(3, $data); + self::assertSame('Closest', $data[0]['name']); + self::assertSame('Second', $data[1]['name']); + self::assertSame('Third', $data[2]['name']); + self::assertSame(['x' => 0.1, 'y' => 0.5], $data[0]['location']); + self::assertSame(0.5099, $data[0]['distance']); + } + + public function testDistancesAreRoundedToFourDecimalPlaces(): void + { + $kernel = self::bootKernel(); + $this->replaceProviderLocations([ + self::location('Diagonal', 1.0, 1.0), + ]); + + $response = $kernel->handle(Request::create(self::ENDPOINT.'?x=0.0&y=0.0')); + $data = self::json($response); + + self::assertSame(1.4142, $data[0]['distance']); + } + + public function testResponseContainsLocationWithXAndY(): void + { + $kernel = self::bootKernel(); + $this->replaceProviderLocations([ + self::location('Asymmetric', 3.5, -2.5), + ]); + + $response = $kernel->handle(Request::create(self::ENDPOINT.'?x=0.0&y=0.0')); + $data = self::json($response); + + self::assertSame(3.5, $data[0]['location']['x']); + self::assertSame(-2.5, $data[0]['location']['y']); + } + + public function testMissingXReturnsBadRequest(): void + { + $kernel = self::bootKernel(); + $response = $kernel->handle(Request::create(self::ENDPOINT.'?y=0')); + + $this->assertError( + $response, + Response::HTTP_BAD_REQUEST, + 'INVALID_COORDINATES', + 'Missing required query parameter "x".', + ); + } + + public function testMissingYReturnsBadRequest(): void + { + $kernel = self::bootKernel(); + $response = $kernel->handle(Request::create(self::ENDPOINT.'?x=0')); + + $this->assertError( + $response, + Response::HTTP_BAD_REQUEST, + 'INVALID_COORDINATES', + 'Missing required query parameter "y".', + ); + } + + public function testNonNumericXReturnsBadRequest(): void + { + $kernel = self::bootKernel(); + $response = $kernel->handle(Request::create(self::ENDPOINT.'?x=abc&y=0')); + + $this->assertError( + $response, + Response::HTTP_BAD_REQUEST, + 'INVALID_COORDINATES', + 'Query parameter "x" must be a finite number.', + ); + } + + #[DataProvider('nonFiniteXValues')] + public function testNonFiniteXReturnsBadRequest(string $value): void + { + $kernel = self::bootKernel(); + $response = $kernel->handle(Request::create(self::ENDPOINT.'?x='.$value.'&y=0')); + + $this->assertError( + $response, + Response::HTTP_BAD_REQUEST, + 'INVALID_COORDINATES', + 'Query parameter "x" must be a finite number.', + ); + } + + /** + * @return iterable + */ + public static function nonFiniteXValues(): iterable + { + yield 'NAN' => ['NAN']; + yield '1e309' => ['1e309']; + } + + public function testArrayXReturnsBadRequest(): void + { + $kernel = self::bootKernel(); + + $response = $kernel->handle(Request::create(self::ENDPOINT.'?x[]=1&y=0')); + + $this->assertError( + $response, + Response::HTTP_BAD_REQUEST, + 'INVALID_COORDINATES', + 'Query parameter "x" must be a finite number.', + ); + } + + public function testCsvFetchExceptionReturns503(): void + { + $kernel = self::bootKernel(); + $this->failProviderWith(new CsvFetchException('upstream down')); + + $response = $kernel->handle(Request::create(self::ENDPOINT.'?x=0&y=0')); + + $this->assertError( + $response, + Response::HTTP_SERVICE_UNAVAILABLE, + 'COFFEE_SHOPS_UNAVAILABLE', + 'Coffee shop data is temporarily unavailable. Please try again later.', + ); + } + + public function testNoValidLocationsExceptionReturns422(): void + { + $kernel = self::bootKernel(); + $this->failProviderWith(new NoValidLocationsException('empty')); + + $response = $kernel->handle(Request::create(self::ENDPOINT.'?x=0&y=0')); + + $this->assertError( + $response, + Response::HTTP_UNPROCESSABLE_ENTITY, + 'NO_VALID_LOCATIONS', + 'No valid coffee shop locations are available.', + ); + } + + public function testUnexpectedThrowableReturns500WithGenericMessage(): void + { + $kernel = self::bootKernel(); + $this->failProviderWith(new \RuntimeException('Private internal detail.')); + + $response = $kernel->handle(Request::create(self::ENDPOINT.'?x=0&y=0')); + + $this->assertError( + $response, + Response::HTTP_INTERNAL_SERVER_ERROR, + 'INTERNAL_ERROR', + 'An unexpected error occurred. Please try again later.', + ); + + self::assertStringNotContainsString('Private internal detail.', (string) $response->getContent()); + } + + /** + * @param iterable $locations + */ + private function replaceProviderLocations(iterable $locations): void + { + $provider = $this->createMock(CoffeeShopProviderInterface::class); + $provider->method('getAll')->willReturn($locations); + self::getContainer()->set(CoffeeShopProviderInterface::class, $provider); + } + + private function failProviderWith(\Throwable $exception): void + { + $provider = $this->createMock(CoffeeShopProviderInterface::class); + $provider->method('getAll')->willThrowException($exception); + self::getContainer()->set(CoffeeShopProviderInterface::class, $provider); + } + + private function assertError(Response $response, int $status, string $code, string $message): void + { + self::assertSame($status, $response->getStatusCode()); + self::assertSame( + ['error' => ['code' => $code, 'message' => $message]], + self::json($response), + ); + } + + /** + * @return array + */ + private static function json(Response $response): array + { + $data = json_decode((string) $response->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($data); + + return $data; + } + + private static function location(string $name, float $x, float $y): NamedLocation + { + return new NamedLocation($name, new Coordinates($x, $y)); + } +} diff --git a/tests/Unit/Infrastructure/CoffeeShop/CsvLocationParserTest.php b/tests/Unit/Infrastructure/CoffeeShop/CsvLocationParserTest.php index 35b82d7..be568c2 100644 --- a/tests/Unit/Infrastructure/CoffeeShop/CsvLocationParserTest.php +++ b/tests/Unit/Infrastructure/CoffeeShop/CsvLocationParserTest.php @@ -76,13 +76,20 @@ public function testThrowsOnEmptyFile(): void iterator_to_array($parser->parse(self::FIXTURES.'/empty.csv'), false); } - public function testThrowsOnInvalidHeader(): void + public function testFirstRowIsTreatedAsDataWhenNotAValidHeader(): void { - $parser = new CsvLocationParser(new NullLogger()); + $handler = new TestHandler(); + $parser = new CsvLocationParser(new Logger('test', [$handler])); - $this->expectException(NoValidLocationsException::class); + $result = iterator_to_array($parser->parse(self::FIXTURES.'/invalid_header.csv'), false); + + self::assertSame(['1'], self::names($result)); - iterator_to_array($parser->parse(self::FIXTURES.'/invalid_header.csv'), false); + $reasons = array_map( + static fn (LogRecord $record): string => (string) ($record->context['reason'] ?? ''), + $handler->getRecords(), + ); + self::assertSame(['non_numeric_x'], $reasons); } public function testThrowsOnHeaderOnly(): void From a532debf7ce5fe803a68f861617efd0306b6787f Mon Sep 17 00:00:00 2001 From: "ionut.branzea" Date: Sun, 31 May 2026 17:34:18 +0300 Subject: [PATCH 06/10] chore: add manual testing helpers and log targets --- Makefile | 12 +++++++++++- docs/requests.http | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 docs/requests.http diff --git a/Makefile b/Makefile index 50bf651..89cb732 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ export DOCKER_USER PHP = $(DOCKER_COMPOSE) exec app php COMPOSER = $(DOCKER_COMPOSE) exec app composer -.PHONY: up down install shell test stan cs-check cs-fix serve +.PHONY: up down install shell test stan cs-check cs-fix serve logs logs-error logs-search up: $(DOCKER_COMPOSE) up -d --build @@ -33,3 +33,13 @@ cs-fix: serve: $(PHP) -S 0.0.0.0:8080 -t public/ + +logs: + $(DOCKER_COMPOSE) exec app sh -c 'tail -f var/log/dev.log' + +logs-error: + $(DOCKER_COMPOSE) exec app sh -c 'grep -i "error\|critical\|exception" var/log/dev.log || true' + +logs-search: + @test -n "$(q)" || (echo 'Usage: make logs-search q=term' && exit 1) + $(DOCKER_COMPOSE) exec app sh -c 'grep -i "$(q)" var/log/dev.log || true' diff --git a/docs/requests.http b/docs/requests.http new file mode 100644 index 0000000..db04142 --- /dev/null +++ b/docs/requests.http @@ -0,0 +1,23 @@ +### Valid request - acceptance example from challenge +GET http://localhost:8080/api/coffee-shops/nearest?x=47.6&y=-122.4 +Accept: application/json + +### Missing x parameter +GET http://localhost:8080/api/coffee-shops/nearest?y=-122.4 +Accept: application/json + +### Missing y parameter +GET http://localhost:8080/api/coffee-shops/nearest?x=47.6 +Accept: application/json + +### Non-numeric x +GET http://localhost:8080/api/coffee-shops/nearest?x=abc&y=-122.4 +Accept: application/json + +### Array input - should return 400 not 500 +GET http://localhost:8080/api/coffee-shops/nearest?x[]=1&y=0 +Accept: application/json + +### Non-finite x +GET http://localhost:8080/api/coffee-shops/nearest?x=1e309&y=0 +Accept: application/json From cfce5bacc37823ba805f4dc871392dc80cb04f3c Mon Sep 17 00:00:00 2001 From: "ionut.branzea" Date: Sun, 31 May 2026 21:56:54 +0300 Subject: [PATCH 07/10] docs: add openapi spec and readme --- README.md | 136 ++++++++++++++++++++++++++----------------- docs/openapi.yaml | 121 ++++++++++++++++++++++++++++++++++++++ public/api-docs.html | 20 +++++++ public/openapi.yaml | 121 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 346 insertions(+), 52 deletions(-) create mode 100644 docs/openapi.yaml create mode 100644 public/api-docs.html create mode 100644 public/openapi.yaml diff --git a/README.md b/README.md index 0b42177..7d78402 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,115 @@ # Coffee Shops Finder API -REST API that returns the three closest coffee shops for a given user location. +A small REST API that returns the three nearest coffee shops for a given user location. Built with PHP 8.3, Symfony 7, and Docker. -## Problem +## Setup -The API receives user coordinates and returns the three nearest coffee shops, ordered from closest to farthest, including the distance from the user. - -Coffee shops are loaded from a remote CSV file: - -```text -Name,X,Y -``` - -Data quality may vary — malformed rows are skipped gracefully. -Distances are rounded to four decimal places. -All coordinates lie on a plane. - -## Example - -Input coordinates: `X=47.6`, `Y=-122.4` - -Expected result: - -```text -Starbucks Seattle2 -Starbucks Seattle -Starbucks SF +```bash +git clone git@github.com:branzeaaurel/coffee-shops-finder.git +cd coffee-shops-finder +make up +make install +make serve ``` -## Endpoint +In another terminal: -```http -GET /api/coffee-shops/nearest?x=47.6&y=-122.4 +```bash +curl "http://localhost:8080/api/coffee-shops/nearest?x=47.6&y=-122.4" ``` -Response: +Expected response: ```json [ { "name": "Starbucks Seattle2", "location": { - "x": 47.6, - "y": -122.4 + "x": 47.5869, + "y": -122.3368 + }, + "distance": 0.0645 + }, + { + "name": "Starbucks Seattle", + "location": { + "x": 47.5809, + "y": -122.316 }, - "distance": 0.0 + "distance": 0.0861 + }, + { + "name": "Starbucks SF", + "location": { + "x": 37.5209, + "y": -122.334 + }, + "distance": 10.0793 } ] ``` -## Stack +## Manual testing -PHP 8.3 · Symfony 7 · Docker +`docs/requests.http` contains example requests for the endpoint: the happy path, missing parameters, non-numeric input, array-style input, and non-finite values. It works well with JetBrains HTTP Client, VS Code REST Client, or as a copy-paste reference for `curl`. -## Running +To browse the API spec interactively, run `make serve` and open +http://localhost:8080/api-docs.html in a browser. -> Requirements: Docker and Docker Compose v2. No PHP needed on the host. +Useful log commands: ```bash -git clone coffee-shops-finder -cd coffee-shops-finder - -make up # build image and start the container -make install # install PHP dependencies via Composer -make test # run PHPUnit test suite -make stan # run PHPStan static analysis -make cs-check # check code style (PHP CS Fixer) -make cs-fix # auto-fix code style violations -make shell # open a shell inside the container -make down # stop and remove the container +make logs # tail dev.log +make logs-error # grep error/critical/exception lines +make logs-search q=fetch # search dev.log for a term ``` -### Fallback (local PHP, no Docker) +## Configuration + +Runtime configuration lives in `.env`: + +- `CSV_FETCH_URL` — upstream CSV URL. +- `CSV_FETCH_TIMEOUT_SECONDS` — HTTP timeout per attempt, defaults to 5. +- `CSV_CACHE_TTL_SECONDS` — how long the local cache file is considered fresh, defaults to 300. + +Use `.env.local` for local overrides. + +## Architecture + +The code is split into four small layers. + +The **domain** contains the generic location logic: coordinates, named locations, distances, the Euclidean distance calculator, and nearest-location selection. It has no Symfony, HTTP, or filesystem concerns. The finder keeps only the current top results instead of sorting the whole dataset. + +The **infrastructure** layer owns the CSV integration. It fetches the remote file, caches the raw CSV locally, and parses it row by row with `fgetcsv`. The parser yields `NamedLocation` objects, so it does not need to load the full file into memory. Malformed rows are skipped and logged with a reason code. + +The **application** layer contains the use case. `FindNearestCoffeeShopsHandler` gets the coffee shops from the provider, delegates nearest-location selection to the domain service, and maps the result to `NearestCoffeeShop`. Distances stay raw here. -If you have PHP 8.3+ and Composer installed locally: +The **HTTP** layer contains the controller and exception subscriber. The controller validates query parameters, creates `Coordinates`, calls the handler, rounds distances to four decimals, and returns a manual `JsonResponse`. The subscriber turns known exceptions into stable JSON errors and hides internal details for unexpected failures. + +## Key decisions and trade-offs + +The endpoint is small, so I kept request validation and response mapping explicit in the controller. Symfony Form and Serializer would be fine for a larger API, but here they would add more setup than value. + +The upstream CSV currently has no header, even though the challenge describes `Name,X,Y`. The parser supports both formats: it skips a `Name,X,Y` header when present, otherwise it treats the first row as data. + +Distance is calculated with the Euclidean formula because the challenge treats coordinates as points on a plane. For real geographic distances, I would swap the calculator for a Haversine implementation behind the same `DistanceCalculatorInterface`. + +The provider uses a stale cache fallback. If the upstream CSV is temporarily unavailable but an older local file exists, the API can still return results. If there is no cache and the upstream fails, the API returns `503`. + +Cache writes are atomic: the new CSV is written to a temporary file and then renamed over the old cache file. That avoids partially written cache files. + +## Running checks ```bash -composer install -./vendor/bin/phpunit -./vendor/bin/phpstan analyse -./vendor/bin/php-cs-fixer check --diff +make test # PHPUnit +make stan # PHPStan level 8 +make cs-check # PHP CS Fixer (Symfony preset) ``` + +## What I would add with more time + +- Exponential backoff with jitter between HTTP retries instead of immediate attempts. +- A circuit breaker on the upstream fetch so transient outages don't slow down every request. +- A shared cache (Redis or similar) so multiple instances aren't each maintaining their own local CSV file. +- A Haversine distance implementation, switched in by changing one DI alias. +- A GitHub Actions workflow running `make install && make test && make stan && make cs-check` on every push. diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..0cd70bc --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,121 @@ +openapi: 3.0.3 +info: + title: Coffee Shops Finder API + version: 1.0.0 + description: Returns the three nearest coffee shops to a given user location. +servers: + - url: http://localhost:8080 +paths: + /api/coffee-shops/nearest: + get: + summary: Find the three nearest coffee shops + parameters: + - name: x + in: query + required: true + description: User x coordinate. + schema: + type: number + - name: y + in: query + required: true + description: User y coordinate. + schema: + type: number + responses: + '200': + description: Up to three nearest coffee shops, ordered by ascending distance. + content: + application/json: + schema: + type: array + maxItems: 3 + items: + $ref: '#/components/schemas/CoffeeShop' + example: + - name: Starbucks Seattle2 + location: + x: 47.5869 + y: -122.3368 + distance: 0.0645 + - name: Starbucks Seattle + location: + x: 47.5809 + y: -122.316 + distance: 0.0861 + - name: Starbucks SF + location: + x: 37.5209 + y: -122.334 + distance: 10.0793 + '400': + description: Missing, non-numeric, array-style, or non-finite x/y. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: + code: INVALID_COORDINATES + message: Query parameter "x" must be a finite number. + '422': + description: Upstream data exists but has no valid locations. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: + code: NO_VALID_LOCATIONS + message: No valid coffee shop locations are available. + '503': + description: Upstream CSV unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: + code: COFFEE_SHOPS_UNAVAILABLE + message: Coffee shop data is temporarily unavailable. Please try again later. + '500': + description: Unexpected internal error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: + code: INTERNAL_ERROR + message: An unexpected error occurred. Please try again later. +components: + schemas: + CoffeeShop: + type: object + required: [name, location, distance] + properties: + name: + type: string + location: + type: object + required: [x, y] + properties: + x: + type: number + y: + type: number + distance: + type: number + description: Euclidean distance from the requested coordinates, rounded to 4 decimal places. + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string diff --git a/public/api-docs.html b/public/api-docs.html new file mode 100644 index 0000000..0d1f4e2 --- /dev/null +++ b/public/api-docs.html @@ -0,0 +1,20 @@ + + + + + Coffee Shops Finder API + + + +
+ + + + diff --git a/public/openapi.yaml b/public/openapi.yaml new file mode 100644 index 0000000..0cd70bc --- /dev/null +++ b/public/openapi.yaml @@ -0,0 +1,121 @@ +openapi: 3.0.3 +info: + title: Coffee Shops Finder API + version: 1.0.0 + description: Returns the three nearest coffee shops to a given user location. +servers: + - url: http://localhost:8080 +paths: + /api/coffee-shops/nearest: + get: + summary: Find the three nearest coffee shops + parameters: + - name: x + in: query + required: true + description: User x coordinate. + schema: + type: number + - name: y + in: query + required: true + description: User y coordinate. + schema: + type: number + responses: + '200': + description: Up to three nearest coffee shops, ordered by ascending distance. + content: + application/json: + schema: + type: array + maxItems: 3 + items: + $ref: '#/components/schemas/CoffeeShop' + example: + - name: Starbucks Seattle2 + location: + x: 47.5869 + y: -122.3368 + distance: 0.0645 + - name: Starbucks Seattle + location: + x: 47.5809 + y: -122.316 + distance: 0.0861 + - name: Starbucks SF + location: + x: 37.5209 + y: -122.334 + distance: 10.0793 + '400': + description: Missing, non-numeric, array-style, or non-finite x/y. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: + code: INVALID_COORDINATES + message: Query parameter "x" must be a finite number. + '422': + description: Upstream data exists but has no valid locations. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: + code: NO_VALID_LOCATIONS + message: No valid coffee shop locations are available. + '503': + description: Upstream CSV unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: + code: COFFEE_SHOPS_UNAVAILABLE + message: Coffee shop data is temporarily unavailable. Please try again later. + '500': + description: Unexpected internal error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: + code: INTERNAL_ERROR + message: An unexpected error occurred. Please try again later. +components: + schemas: + CoffeeShop: + type: object + required: [name, location, distance] + properties: + name: + type: string + location: + type: object + required: [x, y] + properties: + x: + type: number + y: + type: number + distance: + type: number + description: Euclidean distance from the requested coordinates, rounded to 4 decimal places. + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string From 41c9d1d3a70edf91d5f6321241ea4426128f3a22 Mon Sep 17 00:00:00 2001 From: "ionut.branzea" Date: Mon, 1 Jun 2026 08:53:17 +0300 Subject: [PATCH 08/10] ci: add GitHub Actions pipeline --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..566ae7c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + pull_request: + +jobs: + ci: + name: ci + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Start + run: make up + + - name: Install + run: make install + + - name: Test + run: make test + + - name: PHPStan + run: make stan + + - name: CS check + run: make cs-check From e195b4760c987df78a568afd9ccc77609f2d04d8 Mon Sep 17 00:00:00 2001 From: "ionut.branzea" Date: Mon, 1 Jun 2026 09:07:12 +0300 Subject: [PATCH 09/10] ci: suppress node 20 deprecation warning --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 566ae7c..ab5ed34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - name: Start run: make up From 42baadf77a455b5f81e7d0d63176eca9014989e0 Mon Sep 17 00:00:00 2001 From: "ionut.branzea" Date: Mon, 1 Jun 2026 09:12:22 +0300 Subject: [PATCH 10/10] ci: upgrade checkout action to v5 --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab5ed34..4049234 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + uses: actions/checkout@v5 - name: Start run: make up