diff --git a/Makefile b/Makefile index 92629e0..1ea284a 100644 --- a/Makefile +++ b/Makefile @@ -6,30 +6,30 @@ VERSION = 0.1.1 help: @echo "๐Ÿ“‹ Available targets:\n" @echo "๐Ÿ—๏ธ Build & Setup:" - @echo " make start Build development image, boot stack, initialize db, and open browser" - @echo " make dev Build development Docker image" - @echo " make prod Build production Docker images (app + web)" - @echo " make up Boot the Docker stack" - @echo " make down Shut down the Docker stack" - @echo " make init Initialize app (composer, database, fixtures)" + @echo " make start - Build development image, boot stack, initialize db, and open browser" + @echo " make build - Build development Docker image" + @echo " make prod - Build production Docker images (app + web)" + @echo " make up - Boot the Docker stack" + @echo " make down - Shut down the Docker stack" + @echo " make init - Initialize app (composer, database, fixtures)" @echo "\n๐Ÿ”„ Maintenance:" - @echo " make maintain Update composer and npm dependencies" - @echo " make show-composer-updates Show outdated composer packages" - @echo " make update-composer-dependencies Update composer packages" - @echo " make update-npm-dependencies Update npm packages" + @echo " make maintain - Update composer and npm dependencies" + @echo " make show-composer-updates - Show outdated composer packages" + @echo " make update-composer-dependencies - Update composer packages" + @echo " make update-npm-dependencies - Update npm packages" @echo "\n๐Ÿงช Testing & Quality:" - @echo " make test Run backend and frontend tests" - @echo " make quality Run quality checks" - @echo " make phpstan Run static code analysis" - @echo " make style Fix code style" - @echo " make arch Test architecture" - @echo " make coverage Generate coverage report" - @echo "\n๐Ÿ› ๏ธ Development:" - @echo " make shell Open shell on app container" - @echo " make composer Run composer command (use: make composer cmd='install')" - @echo " make npm-build Create frontend build" - @echo " make clear Clear all caches" - @echo " make open Open application in browser\n" + @echo " make test - Run backend and frontend tests" + @echo " make quality - Run quality checks" + @echo " make phpstan - Run static code analysis" + @echo " make style - Fix code style" + @echo " make arch - Test architecture" + @echo " make coverage - Generate coverage report" + @echo "\n๐Ÿ› ๏ธ Development:" + @echo " make shell - Open shell on app container" + @echo " make composer - Run composer command (use: make composer cmd='install')" + @echo " make npm-build - Create frontend build" + @echo " make clear - Clear all caches" + @echo " make open - Open application in browser\n" start: build up init open diff --git a/README.md b/README.md index ef470c8..384ebe0 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,13 @@ ![app-ci-workflow](https://github.com/makomweb/split-fairly/actions/workflows/app-ci.yaml/badge.svg) [![codecov](https://codecov.io/gh/makomweb/split-fairly/graph/badge.svg?token=O6WQ8USL6T)](https://codecov.io/gh/makomweb/split-fairly) -A modern web application for transparently splitting expenses and settling debts among groups. Built with **event sourcing** to maintain a complete audit trail of all financial transactions. +## Goal -Follows DDD principles for separating the application into generic, core, and supporting domains - enforced by [deptrac](https://github.com/deptrac/deptrac). +This is a full-stack, cloud-native web application for tracking and splitting expenses and settling debts among 2 individuals. It is built on PHP 8.4, Symfony 8, MySQL 8, and React 19, and uses **event sourcing** to maintain a complete audit trail of all financial transactions. + +It follows DDD principles to separate the application into generic, core, and supporting domains โ€” enforced by [deptrac](https://github.com/deptrac/deptrac). This makes it easy to achieve full code coverage for the core domain. + +It also showcases Kubernetes deployment using Helm. ## Screenshots @@ -88,66 +92,71 @@ Visit `http://localhost:8000` in your browser. make prod # Deploy to cluster +helm install app ./helm +# or: helm upgrade --install app ./helm # Watch pods come up kubectl get pods -w -# View application logs -kubectl logs deployment/app-split-fairly-app -f -kubectl logs deployment/app-split-fairly-worker -f - -# Access the application -# - Direct: http://localhost:30190 -# - Port-forward: kubectl port-forward svc/app-split-fairly-web 8080:80 +# View logs for all pods with the PHP label (app + worker) +kubectl logs -f -l technology=php -# Login credentials (auto-loaded from fixtures) -# Email: admin@example.com -# Password: secret +# Access the application via NodePort: +# Link: http://localhost:30190 +# Or configure port forwarding via: +kubectl port-forward svc/app-split-fairly-web 8080:80 +# Link: http://localhost:8080 ``` ### Kubernetes Architecture ``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Kubernetes Cluster โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ nginx (web) โ”‚ โ”‚ app (PHP-FPM) โ”‚ โ”‚ -โ”‚ โ”‚ Split-Fairly โ”‚ โ”‚ Deployment โ”‚ โ”‚ -โ”‚ โ”‚ NodePort:30190 โ”‚โ”€โ†’โ”‚ Pods ร— 1 โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ Serves SPA โ”‚ Processes requests โ”‚ -โ”‚ โ”‚ EasyAdmin โ”‚ Event sourcing โ”‚ -โ”‚ โ”‚ โ”‚ Session management โ”‚ -โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ โ”‚ worker โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Pod ร— 1 โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ Async jobs โ”‚ โ”‚ -โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ MySQL โ”‚ โ”‚ -โ”‚ โ”‚ StatefulSet โ”‚ โ”‚ -โ”‚ โ”‚ PVC Storage โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ–ณ โ”‚ -โ”‚ โ”‚ init Job โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ db-init โ”‚ โ”‚ -โ”‚ โ”‚ (one-time) โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Kubernetes Cluster โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ nginx (web) โ”‚ โ”‚ app (PHP-FPM) โ”‚ โ”‚ worker โ”‚ โ”‚ +โ”‚ โ”‚ Deployment โ”‚ โ”‚ Deployment โ”‚ โ”‚ (Messenger) โ”‚ โ”‚ +โ”‚ โ”‚ NodePort:30190 โ”‚ โ”‚ Pod ร— 1 โ”‚ โ”‚ Pod ร— 1 โ”‚ โ”‚ +โ”‚ โ”‚ Pods ร— 1 โ”‚โ”€โ†’โ”‚ FastCGI :9000 โ”‚ โ”‚ One-shot pattern โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ Serves SPA โ”‚ Business logic โ”‚ Async tasks โ”‚ +โ”‚ โ”‚ EasyAdmin โ”‚ API endpoints โ”‚ from queue โ”‚ +โ”‚ โ”‚ Static assets โ”‚ Session mgmt (DB) โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ Event sourcing (DB) โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ” โ”‚ +โ”‚ โ”‚ MySQL StatefulSet โ”‚ โ”‚ +โ”‚ โ”‚ PVC Storage (8Gi) โ”‚ โ”‚ +โ”‚ โ”‚ - Event store โ”‚ โ”‚ +โ”‚ โ”‚ - Sessions โ”‚ โ”‚ +โ”‚ โ”‚ - Application data โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ–ณ โ”‚ +โ”‚ โ”‚ init Job โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ db-init โ”‚ โ”‚ +โ”‚ โ”‚ (one-time) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Grafana Alloy (k8s-monitoring) โ”‚ โ”‚ +โ”‚ โ”‚ - Collects logs from all pods โ”‚ โ”‚ +โ”‚ โ”‚ - Metrics collection & forwarding โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` **Components:** -- **nginx (web)**: Serves React SPA frontend, EasyAdmin assets, proxies API to PHP -- **PHP-FPM (app)**: Symfony backend, handles business logic & API endpoints -- **Worker**: Processes async jobs via Messenger (background tasks) -- **MySQL**: Persistent data storage with PVC -- **db-init Job**: One-time database initialization (schema + fixtures) +- **nginx (web)**: Serves React SPA frontend, static assets, EasyAdmin UI. Proxies API requests to PHP-FPM via FastCGI. +- **PHP-FPM (app)**: Symfony backend handling business logic, API endpoints, and session management. Stores sessions in MySQL. +- **Worker**: Processes async jobs via Messenger with one-shot pattern (processes single message per pod lifecycle, then restarts for fresh environment). +- **MySQL**: Persistent data storage with StatefulSet and PVC. Stores event sourcing audit trail, sessions, and application data. +- **db-init Job**: One-time database initialization (schema, fixtures, migrations). +- **Grafana Alloy**: Log collection and forwarding for observability across all cluster components. diff --git a/backend/composer.json b/backend/composer.json index 7d1c393..d4d2f4b 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -8,13 +8,13 @@ "ext-ctype": "*", "ext-iconv": "*", "doctrine/doctrine-bundle": "^3.2.2", - "doctrine/doctrine-fixtures-bundle": "^4.3", + "doctrine/doctrine-fixtures-bundle": "^4.3.1", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6.2", - "dompdf/dompdf": "^3.1", - "easycorp/easyadmin-bundle": "^4.28", + "dompdf/dompdf": "^3.1.5", + "easycorp/easyadmin-bundle": "^5.0.1", "nelmio/cors-bundle": "^2.6.1", - "phpdocumentor/reflection-docblock": "^5.6.6", + "phpdocumentor/reflection-docblock": "^6.0.2", "phpstan/phpdoc-parser": "^2.3.2", "symfony/browser-kit": "8.0.*", "symfony/console": "8.0.*", @@ -25,6 +25,7 @@ "symfony/http-kernel": "8.0.*", "symfony/intl": "8.0.*", "symfony/messenger": "8.0.*", + "symfony/monolog-bundle": "^4.0.1", "symfony/property-access": "8.0.*", "symfony/property-info": "8.0.*", "symfony/runtime": "8.0.*", @@ -104,11 +105,11 @@ } }, "require-dev": { - "deptrac/deptrac": "^4.5", - "friendsofphp/php-cs-fixer": "^3.93.1", - "phpstan/phpstan": "^2.1.38", - "phpunit/phpunit": "^12.5.8", - "symfony/maker-bundle": "^1.65.1", + "deptrac/deptrac": "^4.6", + "friendsofphp/php-cs-fixer": "^3.94.2", + "phpstan/phpstan": "^2.1.40", + "phpunit/phpunit": "^13.0.5", + "symfony/maker-bundle": "^1.66.0", "symfony/stopwatch": "8.0.*", "symfony/web-profiler-bundle": "8.0.*" } diff --git a/backend/composer.lock b/backend/composer.lock index 10bd5e9..276ee4a 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d68dee5a5cf2d58e0d80cb04c6e1e53c", + "content-hash": "073962760d6f2de03c46e4ea6beddf89", "packages": [ { "name": "doctrine/collections", @@ -177,16 +177,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.1", + "version": "4.4.2", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c" + "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/476f7f0fa6ea4aa5364926db7fabdf6049075722", + "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722", "shasum": "" }, "require": { @@ -202,9 +202,9 @@ "phpstan/phpstan": "2.1.30", "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "11.5.23", - "slevomat/coding-standard": "8.24.0", - "squizlabs/php_codesniffer": "4.0.0", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", "symfony/cache": "^6.3.8|^7.0|^8.0", "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, @@ -263,7 +263,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.1" + "source": "https://github.com/doctrine/dbal/tree/4.4.2" }, "funding": [ { @@ -279,33 +279,33 @@ "type": "tidelift" } ], - "time": "2025-12-04T10:11:03+00:00" + "time": "2026-02-26T12:12:19+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -325,9 +325,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/doctrine-bundle", @@ -951,16 +951,16 @@ }, { "name": "doctrine/migrations", - "version": "3.9.5", + "version": "3.9.6", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "1b823afbc40f932dae8272574faee53f2755eac5" + "reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/1b823afbc40f932dae8272574faee53f2755eac5", - "reference": "1b823afbc40f932dae8272574faee53f2755eac5", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/ffd8355cdd8505fc650d9604f058bf62aedd80a1", + "reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1", "shasum": "" }, "require": { @@ -1034,7 +1034,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.5" + "source": "https://github.com/doctrine/migrations/tree/3.9.6" }, "funding": [ { @@ -1050,7 +1050,7 @@ "type": "tidelift" } ], - "time": "2025-11-20T11:15:36+00:00" + "time": "2026-02-11T06:46:11+00:00" }, { "name": "doctrine/orm", @@ -1235,16 +1235,16 @@ }, { "name": "doctrine/sql-formatter", - "version": "1.5.3", + "version": "1.5.4", "source": { "type": "git", "url": "https://github.com/doctrine/sql-formatter.git", - "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7" + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/a8af23a8e9d622505baa2997465782cbe8bb7fc7", - "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/9563949f5cd3bd12a17d12fb980528bc141c5806", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806", "shasum": "" }, "require": { @@ -1284,22 +1284,22 @@ ], "support": { "issues": "https://github.com/doctrine/sql-formatter/issues", - "source": "https://github.com/doctrine/sql-formatter/tree/1.5.3" + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.4" }, - "time": "2025-10-26T09:35:14+00:00" + "time": "2026-02-08T16:21:46+00:00" }, { "name": "dompdf/dompdf", - "version": "v3.1.4", + "version": "v3.1.5", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "db712c90c5b9868df3600e64e68da62e78a34623" + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", - "reference": "db712c90c5b9868df3600e64e68da62e78a34623", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", "shasum": "" }, "require": { @@ -1348,9 +1348,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" }, - "time": "2025-10-29T12:43:30+00:00" + "time": "2026-03-03T13:54:37+00:00" }, { "name": "dompdf/php-font-lib", @@ -1445,52 +1445,52 @@ }, { "name": "easycorp/easyadmin-bundle", - "version": "v4.28.0", + "version": "v5.0.1", "source": { "type": "git", "url": "https://github.com/EasyCorp/EasyAdminBundle.git", - "reference": "ff284930473028f6be2acc8251a3041ee931d9c0" + "reference": "0bd155748233b5bb77c463008813dec325a1ec82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/ff284930473028f6be2acc8251a3041ee931d9c0", - "reference": "ff284930473028f6be2acc8251a3041ee931d9c0", + "url": "https://api.github.com/repos/EasyCorp/EasyAdminBundle/zipball/0bd155748233b5bb77c463008813dec325a1ec82", + "reference": "0bd155748233b5bb77c463008813dec325a1ec82", "shasum": "" }, "require": { - "doctrine/doctrine-bundle": "^2.5|^3.0", - "doctrine/orm": "^2.12|^3.0", - "php": ">=8.1", - "symfony/asset": "^5.4|^6.0|^7.0|^8.0", - "symfony/cache": "^5.4|^6.0|^7.0|^8.0", - "symfony/config": "^5.4|^6.0|^7.0|^8.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", + "doctrine/dbal": "^3.10|^4.4", + "doctrine/doctrine-bundle": "^2.18|^3.2", + "doctrine/orm": "^2.20|^3.6", + "ext-json": "*", + "php": ">=8.2", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/cache": "^6.4.33|^7.0|^8.0", + "symfony/config": "^6.4.32|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.32|^7.0|^8.0", "symfony/deprecation-contracts": "^3.0", - "symfony/doctrine-bridge": "^5.4|^6.0|^7.0|^8.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0|^8.0", - "symfony/filesystem": "^5.4|^6.0|^7.0|^8.0", - "symfony/form": "^5.4|^6.0|^7.0|^8.0", - "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0|^8.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0|^8.0", - "symfony/intl": "^5.4|^6.0|^7.0|^8.0", - "symfony/property-access": "^5.4|^6.0|^7.0|^8.0", - "symfony/security-bundle": "^5.4|^6.0|^7.0|^8.0", - "symfony/string": "^5.4|^6.0|^7.0|^8.0", - "symfony/translation": "^5.4|^6.0|^7.0|^8.0", - "symfony/twig-bridge": "^5.4.48|^6.4.16|^7.1.9|^8.0", - "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", - "symfony/uid": "^5.4|^6.0|^7.0|^8.0", - "symfony/ux-twig-component": "^2.21", - "symfony/validator": "^5.4|^6.0|^7.0|^8.0", - "twig/extra-bundle": "^3.17", - "twig/html-extra": "^3.17", - "twig/twig": "^3.20" - }, - "conflict": { - "symfony/error-handler": "<5.4.35" + "symfony/doctrine-bridge": "^6.4.32|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4.32|^7.0|^8.0", + "symfony/filesystem": "^6.4.30|^7.0|^8.0", + "symfony/form": "^6.4.32|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.33|^7.0|^8.0", + "symfony/http-foundation": "^6.4.33|^7.0|^8.0", + "symfony/http-kernel": "^6.4.33|^7.0|^8.0", + "symfony/intl": "^6.4.32|^7.0|^8.0", + "symfony/property-access": "^6.4.32|^7.0|^8.0", + "symfony/security-bundle": "^6.4.32|^7.0|^8.0", + "symfony/string": "^6.4.30|^7.0|^8.0", + "symfony/translation": "^6.4.32|^7.0|^8.0", + "symfony/twig-bridge": "^6.4.32|^7.1.9|^7.2|^8.0", + "symfony/twig-bundle": "^6.4.32|^7.0|^8.0", + "symfony/uid": "^6.4.32|^7.0|^8.0", + "symfony/ux-twig-component": "^2.32", + "symfony/validator": "^6.4.33|^7.0|^8.0", + "twig/extra-bundle": "^3.23", + "twig/html-extra": "^3.23", + "twig/twig": "^3.23" }, "require-dev": { + "dama/doctrine-test-bundle": "^8.2", "doctrine/doctrine-fixtures-bundle": "^3.4|3.5.x-dev|^4.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.0", @@ -1498,20 +1498,21 @@ "phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-symfony": "^2.0", "psr/log": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^5.4|^6.0|^7.0|^8.0", - "symfony/css-selector": "^5.4|^6.0|^7.0|^8.0", - "symfony/debug-bundle": "^5.4|^6.0|^7.0|^8.0", - "symfony/dom-crawler": "^5.4|^6.0|^7.0|^8.0", - "symfony/expression-language": "^5.4|^6.0|^7.0|^8.0", - "symfony/phpunit-bridge": "^6.1|^7.0|^8.0", - "symfony/process": "^5.4|^6.0|^7.0|^8.0", - "symfony/web-link": "^5.4|^6.0|^7.0|^8.0", - "vincentlanglet/twig-cs-fixer": "^3.10" + "symfony/browser-kit": "^6.4.32|^7.0|^8.0", + "symfony/css-selector": "^6.4.24|^7.0|^8.0", + "symfony/debug-bundle": "^6.4.27|^7.0|^8.0", + "symfony/dom-crawler": "^6.4.32|^7.0|^8.0", + "symfony/expression-language": "^6.4.32|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4.26|^7.0|^8.0", + "symfony/process": "^6.4.33|^7.0|^8.0", + "symfony/web-link": "^6.4.32|^7.0|^8.0", + "vincentlanglet/twig-cs-fixer": "^3.10", + "zenstruck/foundry": "^2.3" }, "type": "symfony-bundle", "extra": { "branch-alias": { - "dev-master": "4.0.x-dev" + "dev-master": "5.0.x-dev" } }, "autoload": { @@ -1538,7 +1539,7 @@ ], "support": { "issues": "https://github.com/EasyCorp/EasyAdminBundle/issues", - "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v4.28.0" + "source": "https://github.com/EasyCorp/EasyAdminBundle/tree/v5.0.1" }, "funding": [ { @@ -1546,7 +1547,7 @@ "type": "github" } ], - "time": "2026-01-28T19:50:24+00:00" + "time": "2026-03-01T18:53:23+00:00" }, { "name": "masterminds/html5", @@ -1615,6 +1616,109 @@ }, "time": "2025-07-25T09:04:22+00:00" }, + { + "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": "nelmio/cors-bundle", "version": "2.6.1", @@ -1735,16 +1839,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/897b5986ece6b4f9d8413fea345c7d49c757d6bf", + "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf", "shasum": "" }, "require": { @@ -1752,8 +1856,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -1763,7 +1867,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -1793,44 +1898,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.2" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2026-03-01T18:43:49+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -1851,9 +1956,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -2154,33 +2259,35 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v9.1.0", + "version": "v9.3.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb" + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", - "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", "shasum": "" }, "require": { "ext-iconv": "*", "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3" + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "1.4.0", "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.28 || 2.1.25", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.7", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6", - "phpunit/phpunit": "8.5.46", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.1.7", - "rector/type-perfect": "1.0.0 || 2.1.0" + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -2188,10 +2295,14 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "9.4.x-dev" } }, "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], "psr-4": { "Sabberworm\\CSS\\": "src/" } @@ -2222,22 +2333,22 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.1.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" }, - "time": "2025-09-14T07:37:21+00:00" + "time": "2026-03-03T17:31:43+00:00" }, { "name": "symfony/asset", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/asset.git", - "reference": "2401c7e9f223969f0979eeb884a09fa6f8d7e49b" + "reference": "e32d8441a7d5dd8db159fd71501bd11ff269b5a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset/zipball/2401c7e9f223969f0979eeb884a09fa6f8d7e49b", - "reference": "2401c7e9f223969f0979eeb884a09fa6f8d7e49b", + "url": "https://api.github.com/repos/symfony/asset/zipball/e32d8441a7d5dd8db159fd71501bd11ff269b5a4", + "reference": "e32d8441a7d5dd8db159fd71501bd11ff269b5a4", "shasum": "" }, "require": { @@ -2274,7 +2385,7 @@ "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset/tree/v8.0.4" + "source": "https://github.com/symfony/asset/tree/v8.0.6" }, "funding": [ { @@ -2294,7 +2405,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/browser-kit", @@ -2370,16 +2481,16 @@ }, { "name": "symfony/cache", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "92e9960386c7e01f58198038c199d522959a843c" + "reference": "59184fa14658d7724cd9b8743d91c1b1aa618bff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/92e9960386c7e01f58198038c199d522959a843c", - "reference": "92e9960386c7e01f58198038c199d522959a843c", + "url": "https://api.github.com/repos/symfony/cache/zipball/59184fa14658d7724cd9b8743d91c1b1aa618bff", + "reference": "59184fa14658d7724cd9b8743d91c1b1aa618bff", "shasum": "" }, "require": { @@ -2446,7 +2557,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.0.5" + "source": "https://github.com/symfony/cache/tree/v8.0.6" }, "funding": [ { @@ -2466,7 +2577,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:18:07+00:00" + "time": "2026-02-21T23:29:37+00:00" }, { "name": "symfony/cache-contracts", @@ -2623,16 +2734,16 @@ }, { "name": "symfony/config", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "8f45af92f08f82902827a8b6f403aaf49d893539" + "reference": "94ea198de42f93dffa920a098cac3961a82e63b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/8f45af92f08f82902827a8b6f403aaf49d893539", - "reference": "8f45af92f08f82902827a8b6f403aaf49d893539", + "url": "https://api.github.com/repos/symfony/config/zipball/94ea198de42f93dffa920a098cac3961a82e63b7", + "reference": "94ea198de42f93dffa920a098cac3961a82e63b7", "shasum": "" }, "require": { @@ -2677,7 +2788,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v8.0.4" + "source": "https://github.com/symfony/config/tree/v8.0.6" }, "funding": [ { @@ -2697,20 +2808,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/console", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b" + "reference": "488285876e807a4777f074041d8bb508623419fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ace03c4cf9805080ff40cbeec69fca180c339a3b", - "reference": "ace03c4cf9805080ff40cbeec69fca180c339a3b", + "url": "https://api.github.com/repos/symfony/console/zipball/488285876e807a4777f074041d8bb508623419fa", + "reference": "488285876e807a4777f074041d8bb508623419fa", "shasum": "" }, "require": { @@ -2767,7 +2878,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.4" + "source": "https://github.com/symfony/console/tree/v8.0.6" }, "funding": [ { @@ -2787,20 +2898,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/dependency-injection", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26" + "reference": "edd98864a7b9eaaa10f389bd414e7d9e816bb59d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/40a6c455ade7e3bf25900d6b746d40cfa2573e26", - "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/edd98864a7b9eaaa10f389bd414e7d9e816bb59d", + "reference": "edd98864a7b9eaaa10f389bd414e7d9e816bb59d", "shasum": "" }, "require": { @@ -2848,7 +2959,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v8.0.5" + "source": "https://github.com/symfony/dependency-injection/tree/v8.0.6" }, "funding": [ { @@ -2868,7 +2979,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:18:07+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2939,16 +3050,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "0d07589d03ed7db1833bfe943635872a2e8aebb2" + "reference": "ba48ecfce3356d928cb3fe6975963c936a2648c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/0d07589d03ed7db1833bfe943635872a2e8aebb2", - "reference": "0d07589d03ed7db1833bfe943635872a2e8aebb2", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/ba48ecfce3356d928cb3fe6975963c936a2648c3", + "reference": "ba48ecfce3356d928cb3fe6975963c936a2648c3", "shasum": "" }, "require": { @@ -3017,7 +3128,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v8.0.4" + "source": "https://github.com/symfony/doctrine-bridge/tree/v8.0.6" }, "funding": [ { @@ -3037,20 +3148,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/doctrine-messenger", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "81d0b288e90d462896d1dffcff99571dd9d1618c" + "reference": "88329a3faba5023cfb569b3fc5b8a771336c4a88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/81d0b288e90d462896d1dffcff99571dd9d1618c", - "reference": "81d0b288e90d462896d1dffcff99571dd9d1618c", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/88329a3faba5023cfb569b3fc5b8a771336c4a88", + "reference": "88329a3faba5023cfb569b3fc5b8a771336c4a88", "shasum": "" }, "require": { @@ -3093,7 +3204,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v8.0.5" + "source": "https://github.com/symfony/doctrine-messenger/tree/v8.0.6" }, "funding": [ { @@ -3113,20 +3224,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:18:07+00:00" + "time": "2026-02-20T07:51:53+00:00" }, { "name": "symfony/dom-crawler", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "fd78228fa362b41729173183493f46b1df49485f" + "reference": "7f504fe7fb7fa5fee40a653104842cf6f851a6d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/fd78228fa362b41729173183493f46b1df49485f", - "reference": "fd78228fa362b41729173183493f46b1df49485f", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/7f504fe7fb7fa5fee40a653104842cf6f851a6d8", + "reference": "7f504fe7fb7fa5fee40a653104842cf6f851a6d8", "shasum": "" }, "require": { @@ -3163,7 +3274,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v8.0.4" + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.6" }, "funding": [ { @@ -3183,20 +3294,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T09:27:50+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/dotenv", - "version": "v8.0.0", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "460b4067a85288c59a59ce8c1bfb3942e71fd85c" + "reference": "94d59769b0ea491dd8b635089e766519d28773d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/460b4067a85288c59a59ce8c1bfb3942e71fd85c", - "reference": "460b4067a85288c59a59ce8c1bfb3942e71fd85c", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/94d59769b0ea491dd8b635089e766519d28773d6", + "reference": "94d59769b0ea491dd8b635089e766519d28773d6", "shasum": "" }, "require": { @@ -3237,7 +3348,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v8.0.0" + "source": "https://github.com/symfony/dotenv/tree/v8.0.6" }, "funding": [ { @@ -3257,7 +3368,7 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:17:21+00:00" + "time": "2026-02-13T12:00:38+00:00" }, { "name": "symfony/error-handler", @@ -3503,16 +3614,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.1", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", "shasum": "" }, "require": { @@ -3549,7 +3660,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" }, "funding": [ { @@ -3569,20 +3680,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/finder", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", "shasum": "" }, "require": { @@ -3617,7 +3728,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.5" + "source": "https://github.com/symfony/finder/tree/v8.0.6" }, "funding": [ { @@ -3637,7 +3748,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-01-29T09:41:02+00:00" }, { "name": "symfony/flex", @@ -3714,16 +3825,16 @@ }, { "name": "symfony/form", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "c34ec2c2648e2dfedab3ce7e3c6c86f8d89c3092" + "reference": "104947c40b16aea6c7c45c0dc53f4c213940989d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/c34ec2c2648e2dfedab3ce7e3c6c86f8d89c3092", - "reference": "c34ec2c2648e2dfedab3ce7e3c6c86f8d89c3092", + "url": "https://api.github.com/repos/symfony/form/zipball/104947c40b16aea6c7c45c0dc53f4c213940989d", + "reference": "104947c40b16aea6c7c45c0dc53f4c213940989d", "shasum": "" }, "require": { @@ -3785,7 +3896,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v8.0.4" + "source": "https://github.com/symfony/form/tree/v8.0.6" }, "funding": [ { @@ -3805,20 +3916,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/framework-bundle", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "e2f9469e7a802dd7c0d193792afc494d68177c54" + "reference": "86ebd86908edca06e3af5994bc46881575fbe813" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/e2f9469e7a802dd7c0d193792afc494d68177c54", - "reference": "e2f9469e7a802dd7c0d193792afc494d68177c54", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/86ebd86908edca06e3af5994bc46881575fbe813", + "reference": "86ebd86908edca06e3af5994bc46881575fbe813", "shasum": "" }, "require": { @@ -3841,7 +3952,7 @@ }, "conflict": { "doctrine/persistence": "<1.3", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/console": "<7.4", "symfony/form": "<7.4", @@ -3856,7 +3967,7 @@ "require-dev": { "doctrine/persistence": "^1.3|^2|^3", "dragonmantank/cron-expression": "^3.1", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "phpstan/phpdoc-parser": "^1.0|^2.0", "seld/jsonlint": "^1.10", "symfony/asset": "^7.4|^8.0", @@ -3925,7 +4036,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.0.5" + "source": "https://github.com/symfony/framework-bundle/tree/v8.0.6" }, "funding": [ { @@ -3945,20 +4056,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:06:10+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb" + "reference": "7745ff1aad45d855fe25b08969269ef83b1ad8bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb", - "reference": "e3422806e6f6760dbed0ddbc0a7fbfb6b5ce96bb", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7745ff1aad45d855fe25b08969269ef83b1ad8bc", + "reference": "7745ff1aad45d855fe25b08969269ef83b1ad8bc", "shasum": "" }, "require": { @@ -4005,7 +4116,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.0.5" + "source": "https://github.com/symfony/http-foundation/tree/v8.0.6" }, "funding": [ { @@ -4025,20 +4136,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:18:07+00:00" + "time": "2026-02-21T16:28:39+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "20c1c5e41fc53928dbb670088f544f2d460d497d" + "reference": "b567e571e74b5774b3d3cb4d35bdafa5f37e51a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/20c1c5e41fc53928dbb670088f544f2d460d497d", - "reference": "20c1c5e41fc53928dbb670088f544f2d460d497d", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b567e571e74b5774b3d3cb4d35bdafa5f37e51a9", + "reference": "b567e571e74b5774b3d3cb4d35bdafa5f37e51a9", "shasum": "" }, "require": { @@ -4109,7 +4220,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.5" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.6" }, "funding": [ { @@ -4129,20 +4240,20 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:46:31+00:00" + "time": "2026-02-26T08:36:42+00:00" }, { "name": "symfony/intl", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "8d049269c2accca0b02e5f9de39f3ee92ebc4468" + "reference": "4e14323828f51a293edbce15ca98d4f3dd927cbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/8d049269c2accca0b02e5f9de39f3ee92ebc4468", - "reference": "8d049269c2accca0b02e5f9de39f3ee92ebc4468", + "url": "https://api.github.com/repos/symfony/intl/zipball/4e14323828f51a293edbce15ca98d4f3dd927cbf", + "reference": "4e14323828f51a293edbce15ca98d4f3dd927cbf", "shasum": "" }, "require": { @@ -4198,7 +4309,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v8.0.4" + "source": "https://github.com/symfony/intl/tree/v8.0.6" }, "funding": [ { @@ -4218,20 +4329,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/messenger", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "3483db96bcc33310cd1807d2b962e7e01d9f41c2" + "reference": "4be925bf0155d6435d2cdfa63d5ffd277c44ac10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/3483db96bcc33310cd1807d2b962e7e01d9f41c2", - "reference": "3483db96bcc33310cd1807d2b962e7e01d9f41c2", + "url": "https://api.github.com/repos/symfony/messenger/zipball/4be925bf0155d6435d2cdfa63d5ffd277c44ac10", + "reference": "4be925bf0155d6435d2cdfa63d5ffd277c44ac10", "shasum": "" }, "require": { @@ -4288,7 +4399,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v8.0.4" + "source": "https://github.com/symfony/messenger/tree/v8.0.6" }, "funding": [ { @@ -4308,20 +4419,20 @@ "type": "tidelift" } ], - "time": "2026-01-08T22:36:47+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/mime", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "543d01b6ee4b8eb80ce9349186ad530eb8704252" + "reference": "632aef4f15ead4d48c16395e447f2da12543d201" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/543d01b6ee4b8eb80ce9349186ad530eb8704252", - "reference": "543d01b6ee4b8eb80ce9349186ad530eb8704252", + "url": "https://api.github.com/repos/symfony/mime/zipball/632aef4f15ead4d48c16395e447f2da12543d201", + "reference": "632aef4f15ead4d48c16395e447f2da12543d201", "shasum": "" }, "require": { @@ -4331,13 +4442,13 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/dependency-injection": "^7.4|^8.0", "symfony/process": "^7.4|^8.0", "symfony/property-access": "^7.4|^8.0", @@ -4374,7 +4485,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.0.5" + "source": "https://github.com/symfony/mime/tree/v8.0.6" }, "funding": [ { @@ -4394,7 +4505,163 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:06:10+00:00" + "time": "2026-02-05T16:06:41+00:00" + }, + { + "name": "symfony/monolog-bridge", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bridge.git", + "reference": "4dae5fe7f503c0e5ed304db684c3f0d95017e429" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/4dae5fe7f503c0e5ed304db684c3f0d95017e429", + "reference": "4dae5fe7f503c0e5ed304db684c3f0d95017e429", + "shasum": "" + }, + "require": { + "monolog/monolog": "^3", + "php": ">=8.4", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mailer": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^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/v8.0.6" + }, + "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-02-17T13:07:04+00:00" + }, + { + "name": "symfony/monolog-bundle", + "version": "v4.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bundle.git", + "reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/3b4ee2717ee56c5e1edb516140a175eb2a72bc66", + "reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "monolog/monolog": "^3.5", + "php": ">=8.2", + "symfony/config": "^7.3 || ^8.0", + "symfony/dependency-injection": "^7.3 || ^8.0", + "symfony/http-kernel": "^7.3 || ^8.0", + "symfony/monolog-bridge": "^7.3 || ^8.0", + "symfony/polyfill-php84": "^1.30" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.41 || ^12.3", + "symfony/console": "^7.3 || ^8.0", + "symfony/yaml": "^7.3 || ^8.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/v4.0.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": "2025-12-08T08:00:13+00:00" }, { "name": "symfony/options-resolver", @@ -4469,16 +4736,16 @@ }, { "name": "symfony/password-hasher", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/password-hasher.git", - "reference": "ca6af4e20357d58d50c818d676cf2e2dd5e53b02" + "reference": "ff98a0be88030c5f4ba800414f911678cf9dad9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/password-hasher/zipball/ca6af4e20357d58d50c818d676cf2e2dd5e53b02", - "reference": "ca6af4e20357d58d50c818d676cf2e2dd5e53b02", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/ff98a0be88030c5f4ba800414f911678cf9dad9a", + "reference": "ff98a0be88030c5f4ba800414f911678cf9dad9a", "shasum": "" }, "require": { @@ -4518,7 +4785,7 @@ "password" ], "support": { - "source": "https://github.com/symfony/password-hasher/tree/v8.0.4" + "source": "https://github.com/symfony/password-hasher/tree/v8.0.6" }, "funding": [ { @@ -4538,7 +4805,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T23:07:29+00:00" + "time": "2026-02-13T09:57:13+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -5213,16 +5480,16 @@ }, { "name": "symfony/property-info", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "9d987224b54758240e80a062c5e414431bbf84de" + "reference": "97524d06a66ae87c59bf9f137420e843cbe4bea0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/9d987224b54758240e80a062c5e414431bbf84de", - "reference": "9d987224b54758240e80a062c5e414431bbf84de", + "url": "https://api.github.com/repos/symfony/property-info/zipball/97524d06a66ae87c59bf9f137420e843cbe4bea0", + "reference": "97524d06a66ae87c59bf9f137420e843cbe4bea0", "shasum": "" }, "require": { @@ -5231,11 +5498,11 @@ "symfony/type-info": "^7.4.4|^8.0.4" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "phpstan/phpdoc-parser": "^1.0|^2.0", "symfony/cache": "^7.4|^8.0", "symfony/dependency-injection": "^7.4|^8.0", @@ -5275,7 +5542,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v8.0.5" + "source": "https://github.com/symfony/property-info/tree/v8.0.6" }, "funding": [ { @@ -5295,20 +5562,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:18:07+00:00" + "time": "2026-02-13T12:14:15+00:00" }, { "name": "symfony/routing", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "4a2bc08d1c35307239329f434d45c2bfe8241fa9" + "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/4a2bc08d1c35307239329f434d45c2bfe8241fa9", - "reference": "4a2bc08d1c35307239329f434d45c2bfe8241fa9", + "url": "https://api.github.com/repos/symfony/routing/zipball/053c40fd46e1d19c5c5a94cada93ce6c3facdd55", + "reference": "053c40fd46e1d19c5c5a94cada93ce6c3facdd55", "shasum": "" }, "require": { @@ -5355,7 +5622,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.4" + "source": "https://github.com/symfony/routing/tree/v8.0.6" }, "funding": [ { @@ -5375,7 +5642,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/runtime", @@ -5462,16 +5729,16 @@ }, { "name": "symfony/security-bundle", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "c170650a00ba724be3455852747af600a2f042b4" + "reference": "73ba33c215a5e4516c7045c26f6fec71e4ab5727" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c170650a00ba724be3455852747af600a2f042b4", - "reference": "c170650a00ba724be3455852747af600a2f042b4", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/73ba33c215a5e4516c7045c26f6fec71e4ab5727", + "reference": "73ba33c215a5e4516c7045c26f6fec71e4ab5727", "shasum": "" }, "require": { @@ -5538,7 +5805,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v8.0.4" + "source": "https://github.com/symfony/security-bundle/tree/v8.0.6" }, "funding": [ { @@ -5558,7 +5825,7 @@ "type": "tidelift" } ], - "time": "2026-01-10T13:58:55+00:00" + "time": "2026-02-22T22:01:53+00:00" }, { "name": "symfony/security-core", @@ -5644,16 +5911,16 @@ }, { "name": "symfony/security-csrf", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/security-csrf.git", - "reference": "8be8bc615044c5911e6d15a5b0a80132068170c5" + "reference": "60efcc82a33a33df87dcdec3ce3d6915b88958fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-csrf/zipball/8be8bc615044c5911e6d15a5b0a80132068170c5", - "reference": "8be8bc615044c5911e6d15a5b0a80132068170c5", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/60efcc82a33a33df87dcdec3ce3d6915b88958fd", + "reference": "60efcc82a33a33df87dcdec3ce3d6915b88958fd", "shasum": "" }, "require": { @@ -5691,7 +5958,7 @@ "description": "Symfony Security Component - CSRF Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-csrf/tree/v8.0.4" + "source": "https://github.com/symfony/security-csrf/tree/v8.0.6" }, "funding": [ { @@ -5711,20 +5978,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-02-13T09:57:13+00:00" }, { "name": "symfony/security-http", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "02f37c050db6e997052916194086d1a0a8790b8f" + "reference": "ff6cdab586fed68f1ebc2a2ed42ae0dffafada1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/02f37c050db6e997052916194086d1a0a8790b8f", - "reference": "02f37c050db6e997052916194086d1a0a8790b8f", + "url": "https://api.github.com/repos/symfony/security-http/zipball/ff6cdab586fed68f1ebc2a2ed42ae0dffafada1f", + "reference": "ff6cdab586fed68f1ebc2a2ed42ae0dffafada1f", "shasum": "" }, "require": { @@ -5778,7 +6045,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v8.0.4" + "source": "https://github.com/symfony/security-http/tree/v8.0.6" }, "funding": [ { @@ -5798,20 +6065,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/serializer", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "867a38a1927d23a503f7248aa182032c6ea42702" + "reference": "b923bbb92f84213a927db6ad43576366b7b9ec2a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/867a38a1927d23a503f7248aa182032c6ea42702", - "reference": "867a38a1927d23a503f7248aa182032c6ea42702", + "url": "https://api.github.com/repos/symfony/serializer/zipball/b923bbb92f84213a927db6ad43576366b7b9ec2a", + "reference": "b923bbb92f84213a927db6ad43576366b7b9ec2a", "shasum": "" }, "require": { @@ -5819,12 +6086,13 @@ "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", - "symfony/property-info": "<7.3" + "symfony/property-info": "<7.4", + "symfony/type-info": "<7.4" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "phpstan/phpdoc-parser": "^1.0|^2.0", "seld/jsonlint": "^1.10", "symfony/cache": "^7.4|^8.0", @@ -5874,7 +6142,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.0.5" + "source": "https://github.com/symfony/serializer/tree/v8.0.6" }, "funding": [ { @@ -5894,7 +6162,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:06:43+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/service-contracts", @@ -6051,16 +6319,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -6117,7 +6385,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -6137,20 +6405,20 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "symfony/translation", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10" + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", "shasum": "" }, "require": { @@ -6210,7 +6478,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.4" + "source": "https://github.com/symfony/translation/tree/v8.0.6" }, "funding": [ { @@ -6230,7 +6498,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-02-17T13:07:04+00:00" }, { "name": "symfony/translation-contracts", @@ -6316,16 +6584,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "3e60c35cb47b1077524c066ec277eaf92cdc2393" + "reference": "a29b174218f6eb324bf24f60440ac81d17f6ee0d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/3e60c35cb47b1077524c066ec277eaf92cdc2393", - "reference": "3e60c35cb47b1077524c066ec277eaf92cdc2393", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/a29b174218f6eb324bf24f60440ac81d17f6ee0d", + "reference": "a29b174218f6eb324bf24f60440ac81d17f6ee0d", "shasum": "" }, "require": { @@ -6334,14 +6602,14 @@ "twig/twig": "^3.21" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/form": "<7.4.4|>8.0,<8.0.4" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/asset": "^7.4|^8.0", "symfony/asset-mapper": "^7.4|^8.0", "symfony/console": "^7.4|^8.0", @@ -6399,7 +6667,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v8.0.5" + "source": "https://github.com/symfony/twig-bridge/tree/v8.0.6" }, "funding": [ { @@ -6419,7 +6687,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:06:10+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/twig-bundle", @@ -6507,16 +6775,16 @@ }, { "name": "symfony/type-info", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d" + "reference": "785992c06d07306f963ded3439036f5da9b292fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", - "reference": "106a2d3bbf0d4576b2f70e6ca866fa420956ed0d", + "url": "https://api.github.com/repos/symfony/type-info/zipball/785992c06d07306f963ded3439036f5da9b292fe", + "reference": "785992c06d07306f963ded3439036f5da9b292fe", "shasum": "" }, "require": { @@ -6565,7 +6833,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v8.0.4" + "source": "https://github.com/symfony/type-info/tree/v8.0.6" }, "funding": [ { @@ -6585,7 +6853,7 @@ "type": "tidelift" } ], - "time": "2026-01-09T12:15:10+00:00" + "time": "2026-02-20T07:51:53+00:00" }, { "name": "symfony/uid", @@ -6754,16 +7022,16 @@ }, { "name": "symfony/validator", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "ba171e89ee2d01c24c1d8201d59ec595ef4adba1" + "reference": "64bcfc222dd26443c6c68d442a1e65397c440c78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/ba171e89ee2d01c24c1d8201d59ec595ef4adba1", - "reference": "ba171e89ee2d01c24c1d8201d59ec595ef4adba1", + "url": "https://api.github.com/repos/symfony/validator/zipball/64bcfc222dd26443c6c68d442a1e65397c440c78", + "reference": "64bcfc222dd26443c6c68d442a1e65397c440c78", "shasum": "" }, "require": { @@ -6824,7 +7092,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v8.0.5" + "source": "https://github.com/symfony/validator/tree/v8.0.6" }, "funding": [ { @@ -6844,20 +7112,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:06:10+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "326e0406fc315eca57ef5740fa4a280b7a068c82" + "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/326e0406fc315eca57ef5740fa4a280b7a068c82", - "reference": "326e0406fc315eca57ef5740fa4a280b7a068c82", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", + "reference": "2e14f7e0bf5ff02c6e63bd31cb8e4855a13d6209", "shasum": "" }, "require": { @@ -6911,7 +7179,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.0.4" + "source": "https://github.com/symfony/var-dumper/tree/v8.0.6" }, "funding": [ { @@ -6931,7 +7199,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T23:07:29+00:00" + "time": "2026-02-15T10:53:29+00:00" }, { "name": "symfony/var-exporter", @@ -7015,16 +7283,16 @@ }, { "name": "symfony/yaml", - "version": "v8.0.1", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14" + "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", - "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", + "url": "https://api.github.com/repos/symfony/yaml/zipball/5f006c50a981e1630bbb70ad409c5d85f9a716e0", + "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0", "shasum": "" }, "require": { @@ -7066,7 +7334,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.1" + "source": "https://github.com/symfony/yaml/tree/v8.0.6" }, "funding": [ { @@ -7086,20 +7354,20 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:17:06+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "thecodingmachine/safe", - "version": "v3.3.0", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", - "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", "shasum": "" }, "require": { @@ -7209,7 +7477,7 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" }, "funding": [ { @@ -7220,12 +7488,16 @@ "url": "https://github.com/shish", "type": "github" }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, { "url": "https://github.com/staabm", "type": "github" } ], - "time": "2025-05-14T06:15:44+00:00" + "time": "2026-02-04T18:08:13+00:00" }, { "name": "twig/extra-bundle", @@ -7450,16 +7722,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.2", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { @@ -7506,9 +7778,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2026-01-13T14:02:24+00:00" + "time": "2026-02-27T10:28:38+00:00" } ], "packages-dev": [ @@ -7800,43 +8072,43 @@ }, { "name": "deptrac/deptrac", - "version": "4.5.0", + "version": "4.6.0", "source": { "type": "git", "url": "https://github.com/deptrac/deptrac.git", - "reference": "c5346666599dafd23acc6bc47396967956b37385" + "reference": "b27bfc5291a1cbe1c0ebf514716b1d25c68c63fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/deptrac/deptrac/zipball/c5346666599dafd23acc6bc47396967956b37385", - "reference": "c5346666599dafd23acc6bc47396967956b37385", + "url": "https://api.github.com/repos/deptrac/deptrac/zipball/b27bfc5291a1cbe1c0ebf514716b1d25c68c63fd", + "reference": "b27bfc5291a1cbe1c0ebf514716b1d25c68c63fd", "shasum": "" }, "require": { "composer/xdebug-handler": "^3.0", - "jetbrains/phpstorm-stubs": "2024.3", + "jetbrains/phpstorm-stubs": "2024.3 || 2025.3", "nikic/php-parser": "^5", "php": "^8.2", "phpdocumentor/graphviz": "^2.1", - "phpdocumentor/type-resolver": "^1.9.0", - "phpstan/phpdoc-parser": "^1.5.0|^2.1.0", + "phpdocumentor/type-resolver": "^1.9.0 || ^2.0.0", + "phpstan/phpdoc-parser": "^1.5.0 || ^2.1.0", "phpstan/phpstan": "^2.0", "psr/container": "^2.0", "psr/event-dispatcher": "^1.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4 || ^7.4 || ^8.0", + "symfony/console": "^6.4 || ^7.4 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0", "symfony/event-dispatcher-contracts": "^3.4", - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^6.4 || ^7.4 || ^8.0", + "symfony/finder": "^6.4 || ^7.4 || ^8.0", + "symfony/yaml": "^6.4 || ^7.4 || ^8.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8", "ergebnis/composer-normalize": "^2.45", "ext-libxml": "*", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/stopwatch": "^6.4 || ^7.4 || ^8.0" }, "suggest": { "ext-dom": "For using the JUnit output formatter" @@ -7879,9 +8151,9 @@ ], "support": { "issues": "https://github.com/deptrac/deptrac/issues", - "source": "https://github.com/deptrac/deptrac/tree/4.5.0" + "source": "https://github.com/deptrac/deptrac/tree/4.6.0" }, - "time": "2026-01-22T09:57:17+00:00" + "time": "2026-02-02T09:44:37+00:00" }, { "name": "evenement/evenement", @@ -7993,16 +8265,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.93.1", + "version": "v3.94.2", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "b3546ab487c0762c39f308dc1ec0ea2c461fc21a" + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b3546ab487c0762c39f308dc1ec0ea2c461fc21a", - "reference": "b3546ab487c0762c39f308dc1ec0ea2c461fc21a", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7787ceff91365ba7d623ec410b8f429cdebb4f63", + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63", "shasum": "" }, "require": { @@ -8019,7 +8291,7 @@ "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", + "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", @@ -8033,18 +8305,18 @@ "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.7", - "infection/infection": "^0.32", - "justinrainbow/json-schema": "^6.6", + "facile-it/paraunit": "^1.3.1 || ^2.7.1", + "infection/infection": "^0.32.3", + "justinrainbow/json-schema": "^6.6.4", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", - "php-coveralls/php-coveralls": "^2.9", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", - "phpunit/phpunit": "^9.6.31 || ^10.5.60 || ^11.5.48", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.7", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.7", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.51", "symfony/polyfill-php85": "^1.33", - "symfony/var-dumper": "^5.4.48 || ^6.4.26 || ^7.4.0 || ^8.0", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0" + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.4", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.1" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -8085,7 +8357,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.93.1" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.94.2" }, "funding": [ { @@ -8093,27 +8365,27 @@ "type": "github" } ], - "time": "2026-01-28T23:50:50+00:00" + "time": "2026-02-20T16:13:53+00:00" }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2024.3", + "version": "v2025.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs.git", - "reference": "0e82bdfe850c71857ee4ee3501ed82a9fc5d043c" + "url": "https://github.com/JetBrains/phpstorm-stubs", + "reference": "d1ee5e570343bd4276a3d5959e6e1c2530b006d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/0e82bdfe850c71857ee4ee3501ed82a9fc5d043c", - "reference": "0e82bdfe850c71857ee4ee3501ed82a9fc5d043c", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/d1ee5e570343bd4276a3d5959e6e1c2530b006d0", + "reference": "d1ee5e570343bd4276a3d5959e6e1c2530b006d0", "shasum": "" }, "require-dev": { - "friendsofphp/php-cs-fixer": "v3.64.0", - "nikic/php-parser": "v5.3.1", - "phpdocumentor/reflection-docblock": "5.6.0", - "phpunit/phpunit": "11.4.3" + "friendsofphp/php-cs-fixer": "^v3.86", + "nikic/php-parser": "^v5.6", + "phpdocumentor/reflection-docblock": "^5.6", + "phpunit/phpunit": "^12.3" }, "type": "library", "autoload": { @@ -8137,10 +8409,7 @@ "stubs", "type" ], - "support": { - "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2024.3" - }, - "time": "2024-12-14T08:03:12+00:00" + "time": "2025-09-18T15:47:24+00:00" }, { "name": "myclabs/deep-copy", @@ -8433,11 +8702,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.38", + "version": "2.1.40", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", - "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", "shasum": "" }, "require": { @@ -8482,20 +8751,20 @@ "type": "github" } ], - "time": "2026-01-30T17:12:46+00:00" + "time": "2026-02-23T15:04:35+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "13.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "a8b58fde2f4fbc69a064e1f80ff917607cf7737c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/a8b58fde2f4fbc69a064e1f80ff917607cf7737c", + "reference": "a8b58fde2f4fbc69a064e1f80ff917607cf7737c", "shasum": "" }, "require": { @@ -8503,17 +8772,17 @@ "ext-libxml": "*", "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", - "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", - "phpunit/php-text-template": "^5.0", - "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", - "sebastian/version": "^6.0", + "php": ">=8.4", + "phpunit/php-file-iterator": "^7.0", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.0", + "sebastian/lines-of-code": "^5.0", + "sebastian/version": "^7.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -8522,7 +8791,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5.x-dev" + "dev-main": "13.0.x-dev" } }, "autoload": { @@ -8551,7 +8820,7 @@ "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/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/13.0.1" }, "funding": [ { @@ -8571,32 +8840,32 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:05:15+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -8624,36 +8893,48 @@ "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/6.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-06T04:33:26+00:00" }, { "name": "phpunit/php-invoker", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", - "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-pcntl": "*" @@ -8661,7 +8942,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -8688,40 +8969,52 @@ "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/6.0.0" + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:58+00:00" + "time": "2026-02-06T04:34:47+00:00" }, { "name": "phpunit/php-text-template", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", - "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -8748,40 +9041,52 @@ "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/5.0.0" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" } ], - "time": "2025-02-07T04:59:16+00:00" + "time": "2026-02-06T04:36:37+00:00" }, { "name": "phpunit/php-timer", - "version": "8.0.0", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", - "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -8808,28 +9113,40 @@ "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/8.0.0" + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" } ], - "time": "2025-02-07T04:59:38+00:00" + "time": "2026-02-06T04:37:53+00:00" }, { "name": "phpunit/phpunit", - "version": "12.5.8", + "version": "13.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" + "reference": "d57826e8921a534680c613924bfd921ded8047f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d57826e8921a534680c613924bfd921ded8047f4", + "reference": "d57826e8921a534680c613924bfd921ded8047f4", "shasum": "" }, "require": { @@ -8842,21 +9159,22 @@ "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", - "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6.0.0", - "phpunit/php-invoker": "^6.0.0", - "phpunit/php-text-template": "^5.0.0", - "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", - "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", - "sebastian/exporter": "^7.0.2", - "sebastian/global-state": "^8.0.2", - "sebastian/object-enumerator": "^7.0.0", - "sebastian/type": "^6.0.3", - "sebastian/version": "^6.0.0", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^13.0.1", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.0.0", + "sebastian/diff": "^8.0.0", + "sebastian/environment": "^9.0.0", + "sebastian/exporter": "^8.0.0", + "sebastian/global-state": "^9.0.0", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.0", + "sebastian/version": "^7.0.0", "staabm/side-effects-detector": "^1.0.5" }, "bin": [ @@ -8865,7 +9183,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.5-dev" + "dev-main": "13.0-dev" } }, "autoload": { @@ -8897,7 +9215,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.0.5" }, "funding": [ { @@ -8921,7 +9239,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T06:12:29+00:00" + "time": "2026-02-18T12:40:03+00:00" }, { "name": "react/cache", @@ -9451,28 +9769,28 @@ }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.2-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -9496,7 +9814,7 @@ "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/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" }, "funding": [ { @@ -9516,31 +9834,31 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-02-06T04:39:44+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "29b232ddc29c2b114c0358c69b3084e7c3da0d58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/29b232ddc29c2b114c0358c69b3084e7c3da0d58", + "reference": "29b232ddc29c2b114c0358c69b3084e7c3da0d58", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.3", - "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "php": ">=8.4", + "sebastian/diff": "^8.0", + "sebastian/exporter": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -9548,7 +9866,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.1-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -9588,7 +9906,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/8.0.0" }, "funding": [ { @@ -9608,33 +9926,33 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-02-06T04:40:39+00:00" }, { "name": "sebastian/complexity", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", - "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9658,41 +9976,53 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:25+00:00" + "time": "2026-02-06T04:41:32+00:00" }, { "name": "sebastian/diff", - "version": "7.0.0", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2b6d09d7729ee87d605a439469f9dcc39be5ea3", + "reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0", + "phpunit/phpunit": "^13.0", "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -9725,35 +10055,47 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/8.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2026-02-06T04:42:27+00:00" }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "bb64d08145b021b67d5f253308a498b73ab0461e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/bb64d08145b021b67d5f253308a498b73ab0461e", + "reference": "bb64d08145b021b67d5f253308a498b73ab0461e", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "suggest": { "ext-posix": "*" @@ -9761,7 +10103,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -9789,7 +10131,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/9.0.0" }, "funding": [ { @@ -9809,34 +10151,34 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-02-06T04:43:29+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea", + "reference": "dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -9879,7 +10221,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/8.0.0" }, "funding": [ { @@ -9899,35 +10241,35 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-02-06T04:44:28+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7", "shasum": "" }, "require": { - "php": ">=8.3", - "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -9953,7 +10295,7 @@ "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/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0" }, "funding": [ { @@ -9973,33 +10315,33 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-02-06T04:45:13+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", "shasum": "" }, "require": { "nikic/php-parser": "^5.0", - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -10023,42 +10365,54 @@ "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/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-02-06T04:45:54+00:00" }, { "name": "sebastian/object-enumerator", - "version": "7.0.0", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", - "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", "shasum": "" }, "require": { - "php": ">=8.3", - "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -10081,40 +10435,52 @@ "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/7.0.0" + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:48+00:00" + "time": "2026-02-06T04:46:36+00:00" }, { "name": "sebastian/object-reflector", - "version": "5.0.0", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", - "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10137,40 +10503,52 @@ "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/5.0.0" + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:17+00:00" + "time": "2026-02-06T04:47:13+00:00" }, { "name": "sebastian/recursion-context", - "version": "7.0.1", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", - "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -10201,7 +10579,7 @@ "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/7.0.1" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" }, "funding": [ { @@ -10221,32 +10599,32 @@ "type": "tidelift" } ], - "time": "2025-08-13T04:44:59+00:00" + "time": "2026-02-06T04:51:28+00:00" }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "42412224607bd3931241bbd17f38e0f972f5a916" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/42412224607bd3931241bbd17f38e0f972f5a916", + "reference": "42412224607bd3931241bbd17f38e0f972f5a916", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^13.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10270,7 +10648,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/7.0.0" }, "funding": [ { @@ -10290,29 +10668,29 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-02-06T04:52:09+00:00" }, { "name": "sebastian/version", - "version": "6.0.0", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", - "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10336,15 +10714,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/version/issues", "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" } ], - "time": "2025-02-07T05:00:38+00:00" + "time": "2026-02-06T04:52:52+00:00" }, { "name": "staabm/side-effects-detector", @@ -10400,16 +10790,16 @@ }, { "name": "symfony/maker-bundle", - "version": "v1.65.1", + "version": "v1.66.0", "source": { "type": "git", "url": "https://github.com/symfony/maker-bundle.git", - "reference": "eba30452d212769c9a5bcf0716959fd8ba1e54e3" + "reference": "b5b4afa2a570b926682e9f34615a6766dd560ff4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/eba30452d212769c9a5bcf0716959fd8ba1e54e3", - "reference": "eba30452d212769c9a5bcf0716959fd8ba1e54e3", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/b5b4afa2a570b926682e9f34615a6766dd560ff4", + "reference": "b5b4afa2a570b926682e9f34615a6766dd560ff4", "shasum": "" }, "require": { @@ -10432,7 +10822,7 @@ }, "require-dev": { "composer/semver": "^3.0", - "doctrine/doctrine-bundle": "^2.5.0|^3.0.0", + "doctrine/doctrine-bundle": "^2.10|^3.0", "doctrine/orm": "^2.15|^3", "doctrine/persistence": "^3.1|^4.0", "symfony/http-client": "^6.4|^7.0|^8.0", @@ -10474,7 +10864,7 @@ ], "support": { "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.65.1" + "source": "https://github.com/symfony/maker-bundle/tree/v1.66.0" }, "funding": [ { @@ -10494,7 +10884,7 @@ "type": "tidelift" } ], - "time": "2025-12-02T07:14:37+00:00" + "time": "2026-02-09T08:55:54+00:00" }, { "name": "symfony/process", @@ -10563,16 +10953,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "0d0df8b3601f80b455d0bf40402d104c02d8b6fa" + "reference": "e9a49910bacf2c945975b43ac5c0e901a9b6fe4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/0d0df8b3601f80b455d0bf40402d104c02d8b6fa", - "reference": "0d0df8b3601f80b455d0bf40402d104c02d8b6fa", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/e9a49910bacf2c945975b43ac5c0e901a9b6fe4f", + "reference": "e9a49910bacf2c945975b43ac5c0e901a9b6fe4f", "shasum": "" }, "require": { @@ -10624,7 +11014,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.0.4" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v8.0.6" }, "funding": [ { @@ -10644,7 +11034,7 @@ "type": "tidelift" } ], - "time": "2026-01-07T12:23:22+00:00" + "time": "2026-02-13T09:57:13+00:00" }, { "name": "theseer/tokenizer", diff --git a/backend/config/bundles.php b/backend/config/bundles.php index 2a67cb2..af9f28d 100644 --- a/backend/config/bundles.php +++ b/backend/config/bundles.php @@ -13,4 +13,5 @@ Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], ]; diff --git a/backend/config/packages/monolog.yaml b/backend/config/packages/monolog.yaml new file mode 100644 index 0000000..c631472 --- /dev/null +++ b/backend/config/packages/monolog.yaml @@ -0,0 +1,7 @@ +monolog: + handlers: + main: + type: stream + path: "php://stdout" + level: info + formatter: monolog.formatter.json diff --git a/backend/config/packages/test/monolog.yaml b/backend/config/packages/test/monolog.yaml new file mode 100644 index 0000000..f8a5274 --- /dev/null +++ b/backend/config/packages/test/monolog.yaml @@ -0,0 +1,5 @@ +monolog: + handlers: + main: + type: null + enabled: false diff --git a/backend/config/reference.php b/backend/config/reference.php index 77267c2..f523d87 100644 --- a/backend/config/reference.php +++ b/backend/config/reference.php @@ -208,29 +208,29 @@ * initial_marking?: list, * events_to_dispatch?: list|null, * places?: list, + * name?: scalar|Param|null, + * metadata?: array, * }>, - * transitions: list, * to?: list, * weight?: int|Param, // Default: 1 - * metadata?: list, + * metadata?: array, * }>, - * metadata?: list, + * metadata?: array, * }>, * }, * router?: bool|array{ // Router configuration * enabled?: bool|Param, // Default: false - * resource: scalar|Param|null, + * resource?: scalar|Param|null, * type?: scalar|Param|null, * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null * http_port?: scalar|Param|null, // Default: 80 @@ -353,10 +353,10 @@ * mapping?: array{ * paths?: list, * }, - * default_context?: list, + * default_context?: array, * named_serializers?: array, + * default_context?: array, * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true * }>, @@ -420,7 +420,7 @@ * }, * messenger?: bool|array{ // Messenger configuration * enabled?: bool|Param, // Default: true - * routing?: array, * }>, * serializer?: array{ @@ -433,7 +433,7 @@ * transports?: array, + * options?: array, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null * retry_strategy?: string|array{ * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null @@ -455,7 +455,7 @@ * allow_no_senders?: bool|Param, // Default: true * }, * middleware?: list, * }>, * }>, @@ -627,7 +627,7 @@ * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto" * cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter" * storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null - * policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter. + * policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter. * limiters?: list, * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). @@ -672,7 +672,7 @@ * enabled?: bool|Param, // Default: false * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" * routing?: array, * }, @@ -727,7 +727,7 @@ * dbal?: array{ * default_connection?: scalar|Param|null, * types?: array, * driver_schemes?: array, * connections?: array, * }, * filters?: array, * }>, @@ -967,7 +967,7 @@ * providers?: list, * }, * entity?: array{ - * class: scalar|Param|null, // The full entity class name of your user class. + * class?: scalar|Param|null, // The full entity class name of your user class. * property?: scalar|Param|null, // Default: null * manager_name?: scalar|Param|null, // Default: null * }, @@ -978,8 +978,8 @@ * }>, * }, * ldap?: array{ - * service: scalar|Param|null, - * base_dn: scalar|Param|null, + * service?: scalar|Param|null, + * base_dn?: scalar|Param|null, * search_dn?: scalar|Param|null, // Default: null * search_password?: scalar|Param|null, // Default: null * extra_fields?: list, @@ -990,7 +990,7 @@ * password_attribute?: scalar|Param|null, // Default: null * }, * }>, - * firewalls: array, @@ -1048,9 +1048,9 @@ * user?: scalar|Param|null, // Default: "REMOTE_USER" * }, * login_link?: array{ - * check_route: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". + * check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". * check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false - * signature_properties: list, + * signature_properties?: list, * lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600 * max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null * used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set. @@ -1152,13 +1152,13 @@ * failure_handler?: scalar|Param|null, * realm?: scalar|Param|null, // Default: null * token_extractors?: list, - * token_handler: string|array{ + * token_handler?: string|array{ * id?: scalar|Param|null, * oidc_user_info?: string|array{ - * base_uri: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). + * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). * discovery?: array{ // Enable the OIDC discovery. * cache?: array{ - * id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, * }, * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" @@ -1166,25 +1166,25 @@ * }, * oidc?: array{ * discovery?: array{ // Enable the OIDC discovery. - * base_uri: list, + * base_uri?: list, * cache?: array{ - * id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, * }, * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" - * audience: scalar|Param|null, // Audience set in the token, for validation purpose. - * issuers: list, - * algorithms: list, + * audience?: scalar|Param|null, // Audience set in the token, for validation purpose. + * issuers?: list, + * algorithms?: list, * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). * encryption?: bool|array{ * enabled?: bool|Param, // Default: false * enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false - * algorithms: list, - * keyset: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). + * algorithms?: list, + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). * }, * }, * cas?: array{ - * validation_url: scalar|Param|null, // CAS server validation URL + * validation_url?: scalar|Param|null, // CAS server validation URL * prefix?: scalar|Param|null, // CAS prefix // Default: "cas" * http_client?: scalar|Param|null, // HTTP Client service // Default: null * }, @@ -1331,6 +1331,149 @@ * }, * controllers_json?: scalar|Param|null, // Deprecated: The "twig_component.controllers_json" config option is deprecated, and will be removed in 3.0. // Default: null * } + * @psalm-type MonologConfig = array{ + * use_microseconds?: scalar|Param|null, // Default: true + * channels?: list, + * handlers?: array, + * }>, + * accepted_levels?: list, + * min_level?: scalar|Param|null, // Default: "DEBUG" + * max_level?: scalar|Param|null, // Default: "EMERGENCY" + * buffer_size?: scalar|Param|null, // Default: 0 + * flush_on_overflow?: bool|Param, // Default: false + * handler?: scalar|Param|null, + * url?: scalar|Param|null, + * exchange?: scalar|Param|null, + * exchange_name?: scalar|Param|null, // Default: "log" + * channel?: scalar|Param|null, // Default: null + * bot_name?: scalar|Param|null, // Default: "Monolog" + * use_attachment?: scalar|Param|null, // Default: true + * use_short_attachment?: scalar|Param|null, // Default: false + * include_extra?: scalar|Param|null, // Default: false + * icon_emoji?: scalar|Param|null, // Default: null + * webhook_url?: scalar|Param|null, + * exclude_fields?: list, + * token?: scalar|Param|null, + * region?: scalar|Param|null, + * source?: scalar|Param|null, + * use_ssl?: bool|Param, // Default: true + * user?: mixed, + * title?: scalar|Param|null, // Default: null + * host?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 514 + * config?: list, + * members?: list, + * connection_string?: scalar|Param|null, + * timeout?: scalar|Param|null, + * time?: scalar|Param|null, // Default: 60 + * deduplication_level?: scalar|Param|null, // Default: 400 + * store?: scalar|Param|null, // Default: null + * connection_timeout?: scalar|Param|null, + * persistent?: bool|Param, + * message_type?: scalar|Param|null, // Default: 0 + * parse_mode?: scalar|Param|null, // Default: null + * disable_webpage_preview?: bool|Param|null, // Default: null + * disable_notification?: bool|Param|null, // Default: null + * split_long_messages?: bool|Param, // Default: false + * delay_between_messages?: bool|Param, // Default: false + * topic?: int|Param, // Default: null + * factor?: int|Param, // Default: 1 + * tags?: list, + * console_formatter_options?: mixed, // Default: [] + * formatter?: scalar|Param|null, + * nested?: bool|Param, // Default: false + * publisher?: string|array{ + * id?: scalar|Param|null, + * hostname?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 12201 + * chunk_size?: scalar|Param|null, // Default: 1420 + * encoder?: "json"|"compressed_json"|Param, + * }, + * mongodb?: string|array{ + * id?: scalar|Param|null, // ID of a MongoDB\Client service + * uri?: scalar|Param|null, + * username?: scalar|Param|null, + * password?: scalar|Param|null, + * database?: scalar|Param|null, // Default: "monolog" + * collection?: scalar|Param|null, // Default: "logs" + * }, + * elasticsearch?: string|array{ + * id?: scalar|Param|null, + * hosts?: list, + * host?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 9200 + * transport?: scalar|Param|null, // Default: "Http" + * user?: scalar|Param|null, // Default: null + * password?: scalar|Param|null, // Default: null + * }, + * index?: scalar|Param|null, // Default: "monolog" + * document_type?: scalar|Param|null, // Default: "logs" + * ignore_error?: scalar|Param|null, // Default: false + * redis?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * password?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 6379 + * database?: scalar|Param|null, // Default: 0 + * key_name?: scalar|Param|null, // Default: "monolog_redis" + * }, + * predis?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * }, + * from_email?: scalar|Param|null, + * to_email?: list, + * subject?: scalar|Param|null, + * content_type?: scalar|Param|null, // Default: null + * headers?: list, + * mailer?: scalar|Param|null, // Default: null + * email_prototype?: string|array{ + * id?: scalar|Param|null, + * method?: scalar|Param|null, // Default: null + * }, + * verbosity_levels?: array{ + * VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR" + * VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING" + * VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE" + * VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO" + * VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG" + * }, + * channels?: string|array{ + * type?: scalar|Param|null, + * elements?: list, + * }, + * }>, + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1343,6 +1486,7 @@ * nelmio_cors?: NelmioCorsConfig, * twig_extra?: TwigExtraConfig, * twig_component?: TwigComponentConfig, + * monolog?: MonologConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1357,6 +1501,7 @@ * nelmio_cors?: NelmioCorsConfig, * twig_extra?: TwigExtraConfig, * twig_component?: TwigComponentConfig, + * monolog?: MonologConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1370,6 +1515,7 @@ * nelmio_cors?: NelmioCorsConfig, * twig_extra?: TwigExtraConfig, * twig_component?: TwigComponentConfig, + * monolog?: MonologConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1384,6 +1530,7 @@ * nelmio_cors?: NelmioCorsConfig, * twig_extra?: TwigExtraConfig, * twig_component?: TwigComponentConfig, + * monolog?: MonologConfig, * }, * ... - diff --git a/backend/src/Controller/API/CalculateExpensesController.php b/backend/src/Controller/API/CalculateExpensesController.php index 4d2d77a..705c40c 100644 --- a/backend/src/Controller/API/CalculateExpensesController.php +++ b/backend/src/Controller/API/CalculateExpensesController.php @@ -5,11 +5,14 @@ use App\Async\Stopwatch; use App\Instrumentation\InstrumentationHolder; use App\Invariant\Ensure; +use App\Repository\UserRepository; use App\SplitFairly\Calculator; use App\SplitFairly\Compensation; use App\SplitFairly\Expenses; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('/api', name: 'api.')] @@ -17,25 +20,72 @@ class CalculateExpensesController extends AbstractController { public function __construct( private readonly Calculator $calculator, + private readonly UserRepository $userRepository, ) { } #[Route('/calculate', name: 'calculate', methods: ['GET'])] - public function calculate(): JsonResponse + public function calculate(Request $request): JsonResponse { $stopwatch = Stopwatch::start(); - $expenses = $this->calculator->calculate(); + $currentUser = $this->getUser(); + if (!$currentUser) { + return $this->json([ + 'error' => 'Please login first!', + ], Response::HTTP_UNAUTHORIZED); + } + $expenses = $this->calculator->calculate(); Ensure::that(2 === count($expenses), 'Track your expenses first!'); + // Check if specific user requested + $withUserEmail = $request->query->get('with_user'); + + if ($withUserEmail) { + // Find the specific user to calculate with + $selectedUser = $this->userRepository->findOneBy(['email' => $withUserEmail]); + if (!$selectedUser) { + return $this->json([ + 'error' => sprintf('User %s not found!', $withUserEmail), + ], Response::HTTP_BAD_REQUEST); + } + + // Prevent self-calculation + if ($selectedUser->getEmail() === $currentUser->getUserIdentifier()) { + return $this->json([ + 'error' => 'Cannot settle up with yourself!', + ], Response::HTTP_BAD_REQUEST); + } + + // Filter expenses to only the current user and selected user + $currentUserExpenses = null; + $selectedUserExpenses = null; + + foreach ($expenses as $expense) { + if ($expense->userEmail === $currentUser->getUserIdentifier()) { + $currentUserExpenses = $expense; + } elseif ($expense->userEmail === $selectedUser->getEmail()) { + $selectedUserExpenses = $expense; + } + } + + if (!$currentUserExpenses || !$selectedUserExpenses) { + return $this->json([ + 'error' => 'Could not find expenses for selected users', + ], Response::HTTP_BAD_REQUEST); + } + + $expenses = [$currentUserExpenses, $selectedUserExpenses]; + } + $compensation = Compensation::calculate($expenses[0], $expenses[1]); InstrumentationHolder::getMetrics() ->record('calculate_compensation', $stopwatch->getMillisecondsElapsed(), 'ms'); InstrumentationHolder::getLogging() - ->info(sprintf('Calculated: %s', $compensation)); + ->info(sprintf('Calculated: %s (withUser: %s)', $compensation, $withUserEmail ?? 'all')); return $this->json([ 'users' => array_map( diff --git a/backend/src/Controller/API/ListUsersController.php b/backend/src/Controller/API/ListUsersController.php new file mode 100644 index 0000000..aa9af7b --- /dev/null +++ b/backend/src/Controller/API/ListUsersController.php @@ -0,0 +1,41 @@ +getUser(); + + if (!$currentUser) { + return $this->json([ + 'error' => 'Please login first!', + ], Response::HTTP_UNAUTHORIZED); + } + + // Get all users except the current user + $allUsers = $this->userRepository->findAll(); + $otherUsers = array_filter($allUsers, fn ($user) => $user->getUserIdentifier() !== $currentUser->getUserIdentifier()); + + $users = array_map(fn ($user) => [ + 'id' => $user->getUuid()->toRfc4122(), + 'email' => $user->getEmail(), + ], $otherUsers); + + return $this->json(['users' => array_values($users)]); + } +} diff --git a/backend/src/Controller/API/TrackExpenseController.php b/backend/src/Controller/API/TrackExpenseController.php index 4b37758..69550d0 100644 --- a/backend/src/Controller/API/TrackExpenseController.php +++ b/backend/src/Controller/API/TrackExpenseController.php @@ -2,10 +2,12 @@ namespace App\Controller\API; +use App\Repository\UserRepository; use App\SplitFairly\Expense; use App\SplitFairly\ExpenseTracker; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\Routing\Attribute\Route; @@ -14,12 +16,42 @@ class TrackExpenseController extends AbstractController { public function __construct( private readonly ExpenseTracker $tracker, + private readonly UserRepository $userRepository, ) { } #[Route('/track', name: 'track', methods: ['POST'])] public function track(#[MapRequestPayload] Expense $expense): JsonResponse { + $currentUser = $this->getUser(); + + if (!$currentUser) { + return $this->json([ + 'error' => 'Please login first!', + ], Response::HTTP_UNAUTHORIZED); + } + + // Validate Lend expenses: location must be recipient's email and must differ from current user + if ('Lend' === $expense->type) { + $currentUserEmail = $currentUser->getUserIdentifier(); + $recipientEmail = $expense->location; + + // Check recipient email is not the same as current user + if ($recipientEmail === $currentUserEmail) { + return $this->json([ + 'error' => 'Cannot lend money to yourself!', + ], Response::HTTP_BAD_REQUEST); + } + + // Check recipient email exists in the system + $recipientUser = $this->userRepository->findOneBy(['email' => $recipientEmail]); + if (!$recipientUser) { + return $this->json([ + 'error' => sprintf('User %s not found!', $recipientEmail), + ], Response::HTTP_BAD_REQUEST); + } + } + $this->tracker->track($expense); return $this->json($expense); diff --git a/backend/src/Controller/Admin/DashboardController.php b/backend/src/Controller/Admin/DashboardController.php index 8e0e95e..1cffdf4 100644 --- a/backend/src/Controller/Admin/DashboardController.php +++ b/backend/src/Controller/Admin/DashboardController.php @@ -2,15 +2,15 @@ namespace App\Controller\Admin; -use App\Entity\User; +use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard; use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard; use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController; use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[AdminDashboard(routePath: '/admin', routeName: 'admin')] class DashboardController extends AbstractDashboardController { public function __construct( @@ -19,7 +19,6 @@ public function __construct( } #[IsGranted('ROLE_ADMIN')] - #[Route('/admin', name: 'admin')] public function index(): Response { $url = $this->adminUrlGenerator @@ -38,6 +37,17 @@ public function configureDashboard(): Dashboard public function configureMenuItems(): iterable { yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home'); - yield MenuItem::linkToCrud('Users', 'fa fa-user', User::class); + + yield MenuItem::linkToRoute( + 'Users', + 'fa fa-user', + 'admin_user_index' + ); + + yield MenuItem::linkToRoute( + 'Events', + 'fa fa-history', + 'admin_event_index' + ); } } diff --git a/backend/src/Controller/Admin/EventCrudController.php b/backend/src/Controller/Admin/EventCrudController.php new file mode 100644 index 0000000..922b107 --- /dev/null +++ b/backend/src/Controller/Admin/EventCrudController.php @@ -0,0 +1,116 @@ + + * + * Admin interface for viewing and managing domain events stored in event sourcing. + * Most event fields are immutable (id, createdAt, createdBy, subjectType, subjectId, eventType), + * but the payload can be edited for correcting event data, and events can be deleted if needed. + */ +class EventCrudController extends AbstractCrudController +{ + public static function getEntityFqcn(): string + { + return Event::class; + } + + public function configureCrud(Crud $crud): Crud + { + return $crud + ->setEntityLabelInSingular('Event') + ->setEntityLabelInPlural('Events') + ->setSearchFields(['createdBy', 'subjectType', 'subjectId', 'eventType']) + ->setDefaultSort(['createdAt' => 'DESC']) + ->setPageTitle(Crud::PAGE_INDEX, 'Events') + ->setPageTitle(Crud::PAGE_DETAIL, 'Event Details') + ->setPageTitle(Crud::PAGE_EDIT, 'Edit Event'); + } + + public function configureActions(Actions $actions): Actions + { + return $actions + ->remove(Crud::PAGE_INDEX, Action::NEW) + ->add(Crud::PAGE_INDEX, Action::DETAIL); + } + + public function configureFields(string $pageName): iterable + { + yield IdField::new('id') + ->hideOnForm() + ->setHelp('UUID v7'); + + yield DateTimeField::new('createdAt') + ->hideOnForm() + ->setFormat('yyyy-MM-dd HH:mm:ss'); + + yield TextField::new('createdBy') + ->hideOnForm() + ->setHelp('User UUID who triggered this event'); + + yield TextField::new('subjectType') + ->hideOnForm() + ->setHelp('Type of entity affected (e.g., Group, Expense)'); + + // yield TextField::new('subjectId') + // ->hideOnForm() + // ->setHelp('ID of the entity affected'); + + yield TextField::new('eventType') + ->hideOnForm() + ->setHelp('Type of domain event (e.g., ExpenseCreated, ExpenseUpdated)'); + + // Note: payload field is added manually in form builders to avoid EasyAdmin's field configurator + // trying to convert the array value to a string during field configuration + } + + public function createEditFormBuilder( + EntityDto $entityDto, + KeyValueStore $formOptions, + AdminContext $context, + ): FormBuilderInterface { + $builder = parent::createEditFormBuilder($entityDto, $formOptions, $context); + $this->addPayloadField($builder); + + return $builder; + } + + public function createNewFormBuilder( + EntityDto $entityDto, + KeyValueStore $formOptions, + AdminContext $context, + ): FormBuilderInterface { + $builder = parent::createNewFormBuilder($entityDto, $formOptions, $context); + $this->addPayloadField($builder); + + return $builder; + } + + private function addPayloadField(FormBuilderInterface $builder): void + { + $builder->add('payload', TextareaType::class, [ + 'label' => 'Payload', + 'help' => 'Event payload (JSON format)', + 'attr' => ['style' => 'font-family: monospace; height: 300px'], + 'required' => false, + ]); + + $builder->get('payload')->addModelTransformer(new JsonToStringTransformer()); + } +} diff --git a/backend/src/Form/DataTransformer/JsonToStringTransformer.php b/backend/src/Form/DataTransformer/JsonToStringTransformer.php new file mode 100644 index 0000000..d1e1acc --- /dev/null +++ b/backend/src/Form/DataTransformer/JsonToStringTransformer.php @@ -0,0 +1,67 @@ + + */ +class JsonToStringTransformer implements DataTransformerInterface +{ + /** + * Transform array to JSON string for display in form. + * + * @param array|string|null $value + */ + public function transform($value): string + { + if (null === $value || '' === $value) { + return ''; + } + + if (is_array($value)) { + $encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + + return is_string($encoded) ? $encoded : ''; + } + + // Must be a string, try to pretty-print it + $decoded = json_decode($value, true); + if (is_array($decoded)) { + $encoded = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + + return is_string($encoded) ? $encoded : $value; + } + + return $value; + } + + /** + * Transform JSON string from form back to array. + * + * @param string|array|null $value + * + * @return array + */ + public function reverseTransform($value): array + { + if (null === $value || '' === $value) { + return []; + } + + if (is_array($value)) { + return $value; + } + + $decoded = json_decode($value, true); + if (is_array($decoded)) { + return $decoded; + } + + return []; + } +} diff --git a/backend/src/Instrumentation/LoggingInterface.php b/backend/src/Instrumentation/LoggingInterface.php index 555f7f4..1876b8d 100644 --- a/backend/src/Instrumentation/LoggingInterface.php +++ b/backend/src/Instrumentation/LoggingInterface.php @@ -8,5 +8,7 @@ interface LoggingInterface { public function info(string|\Stringable $message): void; + public function debug(string|\Stringable $message): void; + public function exception(\Throwable $ex): void; } diff --git a/backend/src/Instrumentation/Null/Logging.php b/backend/src/Instrumentation/Null/Logging.php index 8747d62..1db7b94 100644 --- a/backend/src/Instrumentation/Null/Logging.php +++ b/backend/src/Instrumentation/Null/Logging.php @@ -12,6 +12,10 @@ public function info(string|\Stringable $message): void { } + public function debug(string|\Stringable $message): void + { + } + public function exception(\Throwable $ex): void { } diff --git a/backend/src/Instrumentation/PsrLog/Logging.php b/backend/src/Instrumentation/PsrLog/Logging.php index 5d3e179..c8f9cfd 100644 --- a/backend/src/Instrumentation/PsrLog/Logging.php +++ b/backend/src/Instrumentation/PsrLog/Logging.php @@ -18,6 +18,11 @@ public function info(string|\Stringable $message): void $this->logger->info($message); } + public function debug(string|\Stringable $message): void + { + $this->logger->debug($message); + } + public function exception(\Throwable $ex): void { $this->logger diff --git a/backend/src/Instrumentation/PsrLog/Span.php b/backend/src/Instrumentation/PsrLog/Span.php index 50acb6a..efc95c4 100644 --- a/backend/src/Instrumentation/PsrLog/Span.php +++ b/backend/src/Instrumentation/PsrLog/Span.php @@ -22,7 +22,7 @@ public function __construct( public function open(): void { - $this->logger->info('Entering '.$this->methodName); + $this->logger->debug('Entering '.$this->methodName); } public function recordException(string $context, \Throwable $ex): void @@ -42,9 +42,9 @@ public function close(): void ] ); } - $this->logger->info('Leaving '.$this->methodName); + $this->logger->debug('Leaving '.$this->methodName); } else { - $this->logger->info('Exiting '.$this->methodName); + $this->logger->debug('Exiting '.$this->methodName); } } } diff --git a/backend/src/SplitFairly/Compensation.php b/backend/src/SplitFairly/Compensation.php index 6cee99d..fa4bfa3 100644 --- a/backend/src/SplitFairly/Compensation.php +++ b/backend/src/SplitFairly/Compensation.php @@ -13,12 +13,22 @@ private function __construct( ) { } - public static function calculate(Expenses $a, Expenses $b): self + /** + * @param array $includeTypes + */ + public static function calculate(Expenses $a, Expenses $b, array $includeTypes = ['Groceries', 'Non-Food', 'Lent']): self { - $spentA = $a->spent()->divide(2); - $spentB = $b->spent()->divide(2); + // Determine which amounts to include based on filters + $spentTypes = array_intersect($includeTypes, ['Groceries', 'Non-Food']); + $lentTypes = array_intersect($includeTypes, ['Lent']); + + $spentA = !empty($spentTypes) ? $a->spent($spentTypes)->divide(2) : Price::ZERO(); + $spentB = !empty($spentTypes) ? $b->spent($spentTypes)->divide(2) : Price::ZERO(); $spentDiff = $spentA->substract($spentB); - $lentDiff = $a->lent()->substract($b->lent()); + + $lentA = !empty($lentTypes) ? $a->lent($lentTypes) : Price::ZERO(); + $lentB = !empty($lentTypes) ? $b->lent($lentTypes) : Price::ZERO(); + $lentDiff = $lentA->substract($lentB); $totalDiff = $spentDiff->add($lentDiff); diff --git a/backend/src/SplitFairly/Expenses.php b/backend/src/SplitFairly/Expenses.php index 56367d7..88a2c7b 100644 --- a/backend/src/SplitFairly/Expenses.php +++ b/backend/src/SplitFairly/Expenses.php @@ -72,19 +72,25 @@ static function (array $carry, Expense $expense) use ($filter): array { return $result; } - public function spent(): Price + /** + * @param array $includeTypes + */ + public function spent(array $includeTypes = ['Groceries', 'Non-Food']): Price { return array_reduce( - $this->categories(['Groceries', 'Non-Food']), + $this->categories($includeTypes), static fn (Price $spent, Category $category) => $spent->add($category->sum), Price::ZERO() ); } - public function lent(): Price + /** + * @param array $includeTypes + */ + public function lent(array $includeTypes = ['Lent']): Price { return array_reduce( - $this->categories(['Lent']), + $this->categories($includeTypes), static fn (Price $spent, Category $category) => $spent->add($category->sum), Price::ZERO() ); diff --git a/backend/symfony.lock b/backend/symfony.lock index 9f752c2..c6da1a2 100644 --- a/backend/symfony.lock +++ b/backend/symfony.lock @@ -194,6 +194,18 @@ "config/packages/messenger.yaml" ] }, + "symfony/monolog-bundle": { + "version": "4.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.7", + "ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459" + }, + "files": [ + "config/packages/monolog.yaml" + ] + }, "symfony/property-info": { "version": "8.0", "recipe": { diff --git a/backend/tests/Integration/API/ListUsersEndpointTest.php b/backend/tests/Integration/API/ListUsersEndpointTest.php new file mode 100644 index 0000000..2dfe0fc --- /dev/null +++ b/backend/tests/Integration/API/ListUsersEndpointTest.php @@ -0,0 +1,185 @@ +getContainer(); + $entityManager = $container->get(EntityManagerInterface::class); + $passwordHasher = $container->get(UserPasswordHasherInterface::class); + + \assert($entityManager instanceof EntityManagerInterface); + \assert($passwordHasher instanceof UserPasswordHasherInterface); + + $this->entityManager = $entityManager; + $this->passwordHasher = $passwordHasher; + + // Clean up before each test + $this->entityManager->createQuery('DELETE FROM App\Entity\User')->execute(); + + // Create test users + $this->user1 = User::create('user1@example.com', ['ROLE_USER']); + $plainPassword1 = 'password123'; + $hashedPassword1 = $this->passwordHasher->hashPassword($this->user1, $plainPassword1); + $this->user1->setPassword($hashedPassword1); + + $this->user2 = User::create('user2@example.com', ['ROLE_USER']); + $plainPassword2 = 'password123'; + $hashedPassword2 = $this->passwordHasher->hashPassword($this->user2, $plainPassword2); + $this->user2->setPassword($hashedPassword2); + + $this->user3 = User::create('user3@example.com', ['ROLE_USER']); + $plainPassword3 = 'password123'; + $hashedPassword3 = $this->passwordHasher->hashPassword($this->user3, $plainPassword3); + $this->user3->setPassword($hashedPassword3); + + $this->entityManager->persist($this->user1); + $this->entityManager->persist($this->user2); + $this->entityManager->persist($this->user3); + $this->entityManager->flush(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->entityManager->createQuery('DELETE FROM App\Entity\User')->execute(); + $this->entityManager->close(); + + static::ensureKernelShutdown(); + } + + public function test_list_users_returns_unauthorized_when_not_logged_in(): void + { + static::ensureKernelShutdown(); + $client = static::createClient(); + $client->request('GET', '/api/users'); + + self::assertResponseStatusCodeSame(401); + $responseContent = $client->getResponse()->getContent(); + $response = \is_string($responseContent) ? json_decode($responseContent, true) : null; + $response = \is_array($response) ? $response : []; + self::assertArrayHasKey('error', $response); + } + + public function test_list_users_returns_other_users_when_logged_in(): void + { + static::ensureKernelShutdown(); + $client = static::createClient(); + + // Login as user1 + $crawler = $client->request('GET', '/login'); + $form = $crawler->selectButton('Sign In')->form([ + '_username' => 'user1@example.com', + '_password' => 'password123', + ]); + $client->submit($form); + $client->followRedirect(); + + // Request /api/users + $client->request('GET', '/api/users'); + + self::assertResponseIsSuccessful(); + $responseContent = $client->getResponse()->getContent(); + $response = \is_string($responseContent) ? json_decode($responseContent, true) : null; + $response = \is_array($response) ? $response : []; + + self::assertArrayHasKey('users', $response); + /** @var array $users */ + $users = $response['users'] ?? []; + self::assertCount(2, $users); + + // Verify other users are returned (not user1) + $emails = array_map(function ($u) { + self::assertIsArray($u); + + return $u['email'] ?? null; + }, $users); + self::assertContains('user2@example.com', $emails); + self::assertContains('user3@example.com', $emails); + self::assertNotContains('user1@example.com', $emails); + } + + public function test_list_users_returns_correct_structure(): void + { + static::ensureKernelShutdown(); + $client = static::createClient(); + + // Login as user1 + $crawler = $client->request('GET', '/login'); + $form = $crawler->selectButton('Sign In')->form([ + '_username' => 'user1@example.com', + '_password' => 'password123', + ]); + $client->submit($form); + $client->followRedirect(); + + // Request /api/users + $client->request('GET', '/api/users'); + + $responseContent = $client->getResponse()->getContent(); + $response = \is_string($responseContent) ? json_decode($responseContent, true) : null; + $response = \is_array($response) ? $response : []; + /** @var array $users */ + $users = $response['users'] ?? []; + + // Verify each user has id and email + foreach ($users as $user) { + self::assertIsArray($user); + self::assertArrayHasKey('id', $user); + self::assertArrayHasKey('email', $user); + self::assertNotEmpty($user['id']); + self::assertNotEmpty($user['email']); + } + } + + public function test_list_users_excludes_current_user(): void + { + static::ensureKernelShutdown(); + $client = static::createClient(); + + // Login as user2 + $crawler = $client->request('GET', '/login'); + $form = $crawler->selectButton('Sign In')->form([ + '_username' => 'user2@example.com', + '_password' => 'password123', + ]); + $client->submit($form); + $client->followRedirect(); + + // Request /api/users + $client->request('GET', '/api/users'); + + $responseContent = $client->getResponse()->getContent(); + $response = \is_string($responseContent) ? json_decode($responseContent, true) : null; + $response = \is_array($response) ? $response : []; + /** @var array $users */ + $users = $response['users'] ?? []; + + // Verify user2 is not in the list + $emails = array_map(function ($u) { + self::assertIsArray($u); + + return $u['email'] ?? null; + }, $users); + self::assertNotContains('user2@example.com', $emails); + self::assertContains('user1@example.com', $emails); + self::assertContains('user3@example.com', $emails); + } +} diff --git a/backend/tests/Integration/API/TrackExpenseWithLendValidationTest.php b/backend/tests/Integration/API/TrackExpenseWithLendValidationTest.php new file mode 100644 index 0000000..89bb8be --- /dev/null +++ b/backend/tests/Integration/API/TrackExpenseWithLendValidationTest.php @@ -0,0 +1,196 @@ +getContainer(); + $entityManager = $container->get(EntityManagerInterface::class); + $passwordHasher = $container->get(UserPasswordHasherInterface::class); + + \assert($entityManager instanceof EntityManagerInterface); + \assert($passwordHasher instanceof UserPasswordHasherInterface); + + $this->entityManager = $entityManager; + $this->passwordHasher = $passwordHasher; + + // Clean up before each test + $this->entityManager->createQuery('DELETE FROM App\Entity\User')->execute(); + $this->entityManager->createQuery('DELETE FROM App\Entity\Event')->execute(); + + // Create test users + $this->user1 = User::create('user1@example.com', ['ROLE_USER']); + $plainPassword1 = 'password123'; + $hashedPassword1 = $this->passwordHasher->hashPassword($this->user1, $plainPassword1); + $this->user1->setPassword($hashedPassword1); + + $this->user2 = User::create('user2@example.com', ['ROLE_USER']); + $plainPassword2 = 'password123'; + $hashedPassword2 = $this->passwordHasher->hashPassword($this->user2, $plainPassword2); + $this->user2->setPassword($hashedPassword2); + + $this->entityManager->persist($this->user1); + $this->entityManager->persist($this->user2); + $this->entityManager->flush(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->entityManager->createQuery('DELETE FROM App\Entity\Event')->execute(); + $this->entityManager->createQuery('DELETE FROM App\Entity\User')->execute(); + $this->entityManager->close(); + + static::ensureKernelShutdown(); + } + + public function test_track_lend_expense_to_valid_user(): void + { + static::ensureKernelShutdown(); + $client = static::createClient(); + + // Login as user1 + $crawler = $client->request('GET', '/login'); + $form = $crawler->selectButton('Sign In')->form([ + '_username' => 'user1@example.com', + '_password' => 'password123', + ]); + $client->submit($form); + $client->followRedirect(); + + // Track a Lend expense to user2 + $content = json_encode([ + 'price' => ['value' => 50.00, 'currency' => 'EUR'], + 'what' => 'cash', + 'type' => 'Lend', + 'location' => 'user2@example.com', + ]); + $client->request('POST', '/api/track', [], [], ['CONTENT_TYPE' => 'application/json'], $content ?: '{}'); + + self::assertResponseIsSuccessful(); + $responseContent = $client->getResponse()->getContent(); + $response = \is_string($responseContent) ? json_decode($responseContent, true) : null; + $response = \is_array($response) ? $response : []; + + self::assertArrayHasKey('price', $response); + self::assertArrayHasKey('what', $response); + self::assertArrayHasKey('type', $response); + self::assertArrayHasKey('location', $response); + self::assertEquals('user2@example.com', $response['location']); + } + + public function test_track_lend_expense_to_self_fails(): void + { + static::ensureKernelShutdown(); + $client = static::createClient(); + + // Login as user1 + $crawler = $client->request('GET', '/login'); + $form = $crawler->selectButton('Sign In')->form([ + '_username' => 'user1@example.com', + '_password' => 'password123', + ]); + $client->submit($form); + $client->followRedirect(); + + // Try to track a Lend expense to self + $content = json_encode([ + 'price' => ['value' => 50.00, 'currency' => 'EUR'], + 'what' => 'cash', + 'type' => 'Lend', + 'location' => 'user1@example.com', + ]); + $client->request('POST', '/api/track', [], [], ['CONTENT_TYPE' => 'application/json'], $content ?: '{}'); + + self::assertResponseStatusCodeSame(400); + $responseContent = $client->getResponse()->getContent(); + $response = \is_string($responseContent) ? json_decode($responseContent, true) : null; + $response = \is_array($response) ? $response : []; + + self::assertArrayHasKey('error', $response); + /** @var string $errorMessage */ + $errorMessage = $response['error'] ?? ''; + self::assertStringContainsString('Cannot lend money to yourself', $errorMessage); + } + + public function test_track_lend_expense_to_nonexistent_user_fails(): void + { + static::ensureKernelShutdown(); + $client = static::createClient(); + + // Login as user1 + $crawler = $client->request('GET', '/login'); + $form = $crawler->selectButton('Sign In')->form([ + '_username' => 'user1@example.com', + '_password' => 'password123', + ]); + $client->submit($form); + $client->followRedirect(); + + // Try to track a Lend expense to non-existent user + $content = json_encode([ + 'price' => ['value' => 50.00, 'currency' => 'EUR'], + 'what' => 'cash', + 'type' => 'Lend', + 'location' => 'nonexistent@example.com', + ]); + $client->request('POST', '/api/track', [], [], ['CONTENT_TYPE' => 'application/json'], $content ?: '{}'); + + self::assertResponseStatusCodeSame(400); + $responseContent = $client->getResponse()->getContent(); + $response = \is_string($responseContent) ? json_decode($responseContent, true) : null; + $response = \is_array($response) ? $response : []; + + self::assertArrayHasKey('error', $response); + /** @var string $errorMessage */ + $errorMessage = $response['error'] ?? ''; + self::assertStringContainsString('not found', $errorMessage); + } + + public function test_track_regular_expense_no_validation(): void + { + static::ensureKernelShutdown(); + $client = static::createClient(); + + // Login as user1 + $crawler = $client->request('GET', '/login'); + $form = $crawler->selectButton('Sign In')->form([ + '_username' => 'user1@example.com', + '_password' => 'password123', + ]); + $client->submit($form); + $client->followRedirect(); + + // Track a regular Groceries expense (location can be anything) + $content = json_encode([ + 'price' => ['value' => 25.50, 'currency' => 'EUR'], + 'what' => 'Coffee', + 'type' => 'Groceries', + 'location' => 'Starbucks', + ]); + $client->request('POST', '/api/track', [], [], ['CONTENT_TYPE' => 'application/json'], $content ?: '{}'); + + self::assertResponseIsSuccessful(); + $responseContent = $client->getResponse()->getContent(); + $response = \is_string($responseContent) ? json_decode($responseContent, true) : null; + $response = \is_array($response) ? $response : []; + + self::assertEquals('Starbucks', $response['location'] ?? null); + } +} diff --git a/backend/tests/Unit/Instrumentation/PsrLog/SpanTest.php b/backend/tests/Unit/Instrumentation/PsrLog/SpanTest.php index ae8e10a..2061d25 100644 --- a/backend/tests/Unit/Instrumentation/PsrLog/SpanTest.php +++ b/backend/tests/Unit/Instrumentation/PsrLog/SpanTest.php @@ -21,7 +21,7 @@ protected function setUp(): void public function test_open_logs_debug_message(): void { $this->logger->expects($this->once()) - ->method('info') + ->method('debug') ->with('Entering testMethod'); $span = new Span('testMethod', $this->logger); @@ -31,7 +31,7 @@ public function test_open_logs_debug_message(): void public function test_close_without_exception_logs_exiting(): void { $this->logger->expects($this->atLeast(2)) - ->method('info'); + ->method('debug'); $span = new Span('testMethod', $this->logger); $span->open(); @@ -43,7 +43,7 @@ public function test_close_with_recorded_exception(): void $exception = new \Exception('Test error'); $this->logger->expects($this->atLeast(2)) - ->method('info'); + ->method('debug'); $span = new Span('testMethod', $this->logger); $span->open(); @@ -54,7 +54,7 @@ public function test_close_with_recorded_exception(): void public function test_record_exception_does_not_log(): void { $this->logger->expects($this->once()) - ->method('info'); + ->method('debug'); $span = new Span('testMethod', $this->logger); $span->open(); @@ -64,7 +64,7 @@ public function test_record_exception_does_not_log(): void public function test_span_lifecycle(): void { $this->logger->expects($this->exactly(2)) - ->method('info'); + ->method('debug'); $span = new Span('method1', $this->logger); $span->open(); diff --git a/backend/tests/Unit/SplitFairly/CompensationTest.php b/backend/tests/Unit/SplitFairly/CompensationTest.php index 9466bbf..866c71d 100644 --- a/backend/tests/Unit/SplitFairly/CompensationTest.php +++ b/backend/tests/Unit/SplitFairly/CompensationTest.php @@ -522,7 +522,7 @@ public function test_compensation_with_lent_amounts_from_both_users(): void self::assertSame(70.0, $compensation->settlement->value); } - public function test_compensation_lent_adds_to_spent_difference(): void + public function test_compensation_with_filtered_types_groceries_only(): void { $user1Id = 'user-1'; $user1Email = 'user1@example.com'; @@ -531,16 +531,22 @@ public function test_compensation_lent_adds_to_spent_difference(): void $expenses1 = Expenses::initial($user1Id, $user1Email); $expenses1->add(new Expense( - price: new Price(60.0, 'EUR'), + price: new Price(100.0, 'EUR'), what: 'Groceries', type: 'Groceries', location: 'Market' )); + $expenses1->add(new Expense( + price: new Price(50.0, 'EUR'), + what: 'Non-Food', + type: 'Non-Food', + location: 'Store' + )); $expenses1->add(new Expense( price: new Price(20.0, 'EUR'), - what: 'Money Lent', + what: 'Cash', type: 'Lent', - location: 'Transfer' + location: 'user2@example.com' )); $expenses2 = Expenses::initial($user2Id, $user2Email); @@ -551,13 +557,91 @@ public function test_compensation_lent_adds_to_spent_difference(): void location: 'Market' )); - $compensation = Compensation::calculate($expenses1, $expenses2); + // Include only Groceries + $compensation = Compensation::calculate($expenses1, $expenses2, ['Groceries']); - // Spent diff: 60/2 - 40/2 = 30 - 20 = 10 (User 1 spent 10 more) - // Lent diff: 20 - 0 = 20 (User 1 lent 20 more) - // Total: 10 + 20 = 30 + // Spent diff: 100/2 - 40/2 = 50 - 20 = 30 (User 1 spent 30 more) + // Lent diff: 0 (not included) + // Non-Food: 0 (not included) + // Total: 30 self::assertSame($user2Email, $compensation->from); self::assertSame($user1Email, $compensation->to); self::assertSame(30.0, $compensation->settlement->value); } + + public function test_compensation_with_filtered_types_lent_only(): void + { + $user1Id = 'user-1'; + $user1Email = 'user1@example.com'; + $user2Id = 'user-2'; + $user2Email = 'user2@example.com'; + + $expenses1 = Expenses::initial($user1Id, $user1Email); + $expenses1->add(new Expense( + price: new Price(100.0, 'EUR'), + what: 'Groceries', + type: 'Groceries', + location: 'Market' + )); + $expenses1->add(new Expense( + price: new Price(50.0, 'EUR'), + what: 'Cash', + type: 'Lent', + location: 'user2@example.com' + )); + + $expenses2 = Expenses::initial($user2Id, $user2Email); + $expenses2->add(new Expense( + price: new Price(40.0, 'EUR'), + what: 'Groceries', + type: 'Groceries', + location: 'Market' + )); + $expenses2->add(new Expense( + price: new Price(10.0, 'EUR'), + what: 'Cash', + type: 'Lent', + location: 'user1@example.com' + )); + + // Include only Lent + $compensation = Compensation::calculate($expenses1, $expenses2, ['Lent']); + + // Spent diff: 0 (not included) + // Lent diff: 50 - 10 = 40 (User 1 lent 40 more) + // Total: 40 + self::assertSame($user2Email, $compensation->from); + self::assertSame($user1Email, $compensation->to); + self::assertSame(40.0, $compensation->settlement->value); + } + + public function test_compensation_with_filtered_types_no_types(): void + { + $user1Id = 'user-1'; + $user1Email = 'user1@example.com'; + $user2Id = 'user-2'; + $user2Email = 'user2@example.com'; + + $expenses1 = Expenses::initial($user1Id, $user1Email); + $expenses1->add(new Expense( + price: new Price(100.0, 'EUR'), + what: 'Groceries', + type: 'Groceries', + location: 'Market' + )); + + $expenses2 = Expenses::initial($user2Id, $user2Email); + $expenses2->add(new Expense( + price: new Price(50.0, 'EUR'), + what: 'Groceries', + type: 'Groceries', + location: 'Market' + )); + + // Include no types (empty array) + $compensation = Compensation::calculate($expenses1, $expenses2, []); + + // No types included, so no compensation needed + self::assertSame(0.0, $compensation->settlement->value); + } } diff --git a/build/php/Dockerfile b/build/php/Dockerfile index 6c6f7c2..df4b4d0 100644 --- a/build/php/Dockerfile +++ b/build/php/Dockerfile @@ -1,4 +1,7 @@ -FROM php:8.4-fpm AS base +# ----------------------------------- +# Base stage for Development (Debian-based) +# ----------------------------------- +FROM php:8.4-fpm AS base-dev WORKDIR /var/www/project @@ -15,9 +18,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY build/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini # ----------------------------------- -# Development-Stage (with Composer + XDebug) +# Base stage for Production (Alpine-based, minimal) +# ----------------------------------- +FROM php:8.4.3-fpm-alpine3.20 AS base-prod + +WORKDIR /var/www/project + +RUN apk add --no-cache --virtual .build-deps \ + build-base autoconf icu-dev libzip-dev \ + && apk add --no-cache libintl git zip icu-libs \ + && docker-php-ext-configure intl \ + && docker-php-ext-install intl pdo opcache pdo_mysql zip \ + && pecl install opentelemetry redis \ + && docker-php-ext-enable opentelemetry redis \ + && apk del .build-deps icu-dev libzip-dev + +COPY build/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini + +# ----------------------------------- +# Development-Stage (with Composer + XDebug, from Debian base) # ----------------------------------- -FROM base AS dev +FROM base-dev AS dev RUN echo "memory_limit=512M" > /usr/local/etc/php/conf.d/memory-limit.ini @@ -33,9 +54,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* # ----------------------------------- -# Vendor-Stage (for Production) +# Vendor-Stage (from Debian base for build compatibility) # ----------------------------------- -FROM base AS vendor +FROM base-dev AS vendor COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer @@ -48,7 +69,6 @@ COPY backend/public ./public ENV COMPOSER_ALLOW_SUPERUSER=1 -# https://getcomposer.org/doc/articles/autoloader-optimization.md#optimization-level-2-a-authoritative-class-maps RUN apt-get update && apt-get install -y --no-install-recommends \ libicu-dev git zip unzip \ && composer install \ @@ -70,15 +90,12 @@ COPY frontend . RUN npm install && npm run build -# The vite config builds to ../backend/public/build which is /backend/public/build in this context -# Copy it to /dist for the prod stage to access it RUN cp -r /backend/public/build /dist # ----------------------------------- -# Production-Stage (no Composer, no XDebug) -# Note: from vendor! +# Production-Stage (Alpine-based, lean runtime) # ----------------------------------- -FROM base AS prod +FROM base-prod AS prod COPY --from=vendor /var/www/project/vendor ./vendor COPY --from=frontend /dist ./public/build @@ -92,5 +109,4 @@ COPY backend/public ./public COPY backend/src ./src COPY backend/templates ./templates -RUN mkdir -p var/cache var/log && chmod -R 777 var && \ - sed -i 's/^APP_ENV=.*/APP_ENV=prod/' .env +RUN mkdir -p var/cache var/log && chmod -R 777 var diff --git a/build/php/Dockerfile.alpine b/build/php/Dockerfile.alpine deleted file mode 100644 index b262999..0000000 --- a/build/php/Dockerfile.alpine +++ /dev/null @@ -1,74 +0,0 @@ -FROM php:8.4.3-fpm-alpine3.20 AS base - -WORKDIR /var/www/project - -RUN apk add --no-cache --virtual .build-deps \ - build-base autoconf icu-dev libzip-dev \ - && apk add --no-cache libintl git zip icu-libs \ - && docker-php-ext-configure intl \ - && docker-php-ext-install intl pdo opcache pdo_mysql zip \ - && pecl install opentelemetry redis \ - && docker-php-ext-enable opentelemetry redis \ - && apk del .build-deps icu-dev libzip-dev - -COPY build/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini - -# ----------------------------------- -# Development-Stage (with Composer + XDebug) -# ----------------------------------- -FROM base AS dev - -COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer - -RUN apk add --no-cache --virtual .build-deps \ - build-base linux-headers autoconf && \ - pecl install xdebug && docker-php-ext-enable xdebug && \ - echo "xdebug.mode=debug,coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ - echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ - apk del .build-deps - -EXPOSE 9000 - -CMD ["php-fpm"] - -# ----------------------------------- -# Vendor-Stage (for Production) -# ----------------------------------- -FROM base AS vendor - -COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer - -COPY backend/composer.json . -COPY backend/composer.lock . -COPY backend/.env .env -COPY backend/bin ./bin -COPY backend/src ./src -COPY backend/public ./public - -ENV COMPOSER_ALLOW_SUPERUSER=1 - -RUN apk add --no-cache --virtual .composer-deps \ - icu-libs git zip unzip \ - && composer install \ - --no-dev \ - --optimize-autoloader \ - --classmap-authoritative \ - && apk del .composer-deps - -# ----------------------------------- -# Production-Stage (no Composer, no XDebug) -# ----------------------------------- -FROM base AS prod - -COPY --from=vendor /var/www/backend/vendor ./vendor - -COPY backend/bin ./bin -COPY backend/config ./config -COPY backend/migrations ./migrations -COPY backend/public ./public -COPY backend/src ./src -COPY backend/templates ./templates - -EXPOSE 9000 - -CMD ["php-fpm"] \ No newline at end of file diff --git a/dashboard/assets/config.yml b/dashboard/assets/config.yml index fec8b10..9e0dd6e 100644 --- a/dashboard/assets/config.yml +++ b/dashboard/assets/config.yml @@ -26,8 +26,8 @@ services: icon: "fa-solid fa-tablet-screen-button" url: "http://localhost:5173" target: "_blank" - - name: "User management" - subtitle: "Admin login - EasyAdmin" + - name: "Management" + subtitle: "EasyAdmin (requires admin credentials)" icon: "fa-solid fa-users-cog" url: "http://localhost:8080/admin" target: "_blank" diff --git a/docker-compose.yaml b/docker-compose.yaml index b797067..cfaa8de 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -34,7 +34,16 @@ services: image: ${APP_IMAGE} volumes: - ./backend:/var/www/project - command: ["bin/console", "messenger:consume", "async", "-vv"] + # One-shot worker pattern: processes single message, then exits + # Prevents memory leaks and improves resource predictability + command: + - sh + - -c + - | + while true; do + bin/console messenger:consume async --limit=1 --time-limit=60 -vv + sleep 1 + done depends_on: db: condition: service_healthy diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9acc81f..3fe6e6e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2199,7 +2199,7 @@ "version": "19.2.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2209,7 +2209,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2755,7 +2755,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/data-urls": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d89a09..a2198cc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,19 @@ import './index.css' +import { useState, useRef } from 'react' import { AuthProvider, useAuth } from './features/auth/AuthContext' import { TrackExpense } from './features/expense/TrackExpense' import { Calculation } from './features/calculation/Calculation' import { Button } from './components/ui/button' -import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs' -import { HeartHandshake, Loader } from 'lucide-react' +import { HeartHandshake, Loader, Plus } from 'lucide-react' function AppContent() { const { user, logout, isLoading } = useAuth() + const [currentTab, setCurrentTab] = useState('track') + const [showTrackForm, setShowTrackForm] = useState(true) + const [isFormValid, setIsFormValid] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [hasUserSelected, setHasUserSelected] = useState(false) + const trackFormRef = useRef<{ submit: () => Promise }>(null) if (isLoading) { return ( @@ -23,9 +29,9 @@ function AppContent() { } return ( -
+
{/* Sticky header */} -
+
@@ -46,35 +52,74 @@ function AppContent() {
- {/* Main content with tabs */} - -
-
- - - ๐Ÿ›๏ธ Track - - - ๐Ÿ“Š Calculate - - + {/* Main content */} +
+ {currentTab === 'calculate' && ( + + )} + {currentTab === 'track' && !showTrackForm && ( +
+
+

Use the "+ Track" button to add a new expense

+
-
+ )} + {showTrackForm && ( + setShowTrackForm(false)} + onValidityChange={setIsFormValid} + onLoadingChange={setIsSubmitting} + /> + )} +
- - - - - - - - + {/* Bottom Navigation */} +
+
+ + + +
+
) } diff --git a/frontend/src/features/calculation/Calculation.tsx b/frontend/src/features/calculation/Calculation.tsx index b9e6894..ee65afa 100644 --- a/frontend/src/features/calculation/Calculation.tsx +++ b/frontend/src/features/calculation/Calculation.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { fetchCalculation, CalculationResponse } from './api' +import { fetchUsers, type User } from '@/features/expense/api' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { CompensationCard } from './CompensationCard' @@ -7,16 +8,41 @@ import { UserExpenseCard } from './UserExpenseCard' import { DownloadReportButton } from './DownloadReportButton' import { EmptyState } from './EmptyState' -export function Calculation() { +export function Calculation({ onUserSelected }: { onUserSelected?: (selected: boolean) => void }) { const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) + const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [users, setUsers] = useState([]) + const [usersLoading, setUsersLoading] = useState(false) + const [selectedUser, setSelectedUser] = useState('') - const loadCalculation = async () => { + // Notify parent when user is selected + useEffect(() => { + onUserSelected?.(selectedUser !== '') + }, [selectedUser, onUserSelected]) + + // Focus on person select when component mounts + useEffect(() => { + const timer = setTimeout(() => { + const selectElement = document.getElementById('person-select') as HTMLSelectElement + selectElement?.focus() + }, 200) + return () => clearTimeout(timer) + }, []) + + // Also focus when users finish loading + useEffect(() => { + if (!usersLoading && users.length > 0) { + const selectElement = document.getElementById('person-select') as HTMLSelectElement + selectElement?.focus() + } + }, [usersLoading]) + + const loadCalculation = async (withUser: string = selectedUser) => { try { setLoading(true) setError(null) - const result = await fetchCalculation() + const result = await fetchCalculation(withUser || undefined) setData(result) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load calculation') @@ -26,9 +52,26 @@ export function Calculation() { } useEffect(() => { - loadCalculation() + // Load users for dropdown - don't auto-select + setUsersLoading(true) + fetchUsers() + .then((fetchedUsers) => { + setUsers(fetchedUsers) + // Don't auto-select - let user choose manually + }) + .catch((err) => { + console.error('Failed to load users:', err) + }) + .finally(() => setUsersLoading(false)) }, []) + useEffect(() => { + // Only load calculation when user is explicitly selected + if (selectedUser) { + loadCalculation(selectedUser) + } + }, [selectedUser]) + if (loading) { return (
@@ -44,12 +87,37 @@ export function Calculation() { if (error) { return ( -
-
+
+
+ {/* Person Selector - Always visible at top */} + + +
+ + +
+
+
+

{error}

-
@@ -60,13 +128,49 @@ export function Calculation() { } return ( -
+
- {!data || data.users.length === 0 ? ( + {/* Person Selector - Always visible at top */} + + +
+ + +
+
+
+ + {loading ? ( +
+
๐Ÿ’ฐ
+

Loading calculation...

+
+ ) : !selectedUser ? ( + + ) : !data || data.users.length === 0 ? ( ) : ( <> diff --git a/frontend/src/features/calculation/api.ts b/frontend/src/features/calculation/api.ts index e8d1abb..35d2fe4 100644 --- a/frontend/src/features/calculation/api.ts +++ b/frontend/src/features/calculation/api.ts @@ -26,14 +26,20 @@ export interface CalculationResponse { compensation: Compensation | null } -export async function fetchCalculation(): Promise { - const response = await fetch(getApiUrl('/api/calculate'), { +export async function fetchCalculation(withUser?: string): Promise { + const url = new URL(getApiUrl('/api/calculate'), window.location.origin) + + if (withUser) { + url.searchParams.set('with_user', withUser) + } + + const response = await fetch(url.toString(), { credentials: 'include', }) if (!response.ok) { const error = await response.json() - throw new Error(error.detail || 'Failed to fetch calculation') + throw new Error(error.detail || error.error || 'Failed to fetch calculation') } return response.json() diff --git a/frontend/src/features/expense/ExpenseFormFields.tsx b/frontend/src/features/expense/ExpenseFormFields.tsx index 1f7f6da..5b46fbf 100644 --- a/frontend/src/features/expense/ExpenseFormFields.tsx +++ b/frontend/src/features/expense/ExpenseFormFields.tsx @@ -1,7 +1,9 @@ +import { useEffect, useState, forwardRef } from 'react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { RadioGroup, RadioOption } from '@/components/ui/radio' +import { fetchUsers, type User } from '@/features/expense/api' interface ExpenseFormFieldsProps { what: string @@ -15,6 +17,7 @@ interface ExpenseFormFieldsProps { currency: string onCurrencyChange: (value: string) => void loading: boolean + whatInputRef?: React.RefObject } export function ExpenseFormFields({ @@ -29,52 +32,50 @@ export function ExpenseFormFields({ currency, onCurrencyChange, loading, + whatInputRef, }: ExpenseFormFieldsProps) { + const [users, setUsers] = useState([]) + const [usersLoading, setUsersLoading] = useState(false) + + useEffect(() => { + if (type === 'Lent') { + setUsersLoading(true) + fetchUsers() + .then(setUsers) + .catch(console.error) + .finally(() => setUsersLoading(false)) + } + }, [type]) + + const isLending = type === 'Lent' + const locationLabel = isLending ? 'Whom?' : 'Where?' + const locationPlaceholder = isLending ? 'Select a person...' : 'Starbucks, Downtown...' + return ( <> {/* What field */}
-
- onWhatChange(e.target.value)} - disabled={loading} - required - autoComplete="off" - className="h-12 text-base w-full" - /> -
- - Groceries - Non-Food - Lent - -
-
-
- - {/* Location field */} -
- onLocationChange(e.target.value)} + placeholder="Coffee, Lunch, Taxi..." + value={what} + onChange={(e) => onWhatChange(e.target.value)} disabled={loading} required autoComplete="off" - className="h-12 text-base" + className="h-12 text-base w-full" /> + {/* Type selector */} + + Groceries + Non-Food + Lent +
{/* Price field */} @@ -118,6 +119,42 @@ export function ExpenseFormFields({
+ + {/* Location field */} +
+ + {isLending ? ( + + ) : ( + onLocationChange(e.target.value)} + disabled={loading} + required + autoComplete="off" + className="h-12 text-base" + /> + )} +
) } diff --git a/frontend/src/features/expense/TrackExpense.tsx b/frontend/src/features/expense/TrackExpense.tsx index 6537d41..a9703aa 100644 --- a/frontend/src/features/expense/TrackExpense.tsx +++ b/frontend/src/features/expense/TrackExpense.tsx @@ -1,10 +1,19 @@ -import { useState } from 'react' +import { useState, useImperativeHandle, useRef, forwardRef, useEffect } from 'react' import { Button } from '@/components/ui/button' import { trackExpense } from '@/features/expense/api' import { ExpenseFormFields } from '@/features/expense/ExpenseFormFields' import { FormStatusMessages } from '@/features/expense/FormStatusMessages' -export function TrackExpense() { +interface TrackExpenseProps { + onComplete?: () => void + onValidityChange?: (isValid: boolean) => void + onLoadingChange?: (isLoading: boolean) => void +} + +export const TrackExpense = forwardRef< + { submit: () => Promise }, + TrackExpenseProps +>(({ onComplete, onValidityChange, onLoadingChange }, ref) => { const [price, setPrice] = useState('') const [currency, setCurrency] = useState('EUR') const [what, setWhat] = useState('') @@ -13,12 +22,54 @@ export function TrackExpense() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(false) + const formRef = useRef(null) + const whatInputRef = useRef(null) + + // Focus on "what" field when component mounts + useEffect(() => { + whatInputRef.current?.focus() + }, []) + + // Check form validity + const isFormValid = what.trim() !== '' && location.trim() !== '' && price.trim() !== '' && parseFloat(price) > 0 + + // Notify parent of validity changes + const [prevValid, setPrevValid] = useState(isFormValid) + if (prevValid !== isFormValid) { + setPrevValid(isFormValid) + onValidityChange?.(isFormValid) + } + + // Expose submit method to parent + useImperativeHandle(ref, () => ({ + submit: async () => { + if (formRef.current) { + formRef.current.requestSubmit() + } + }, + focus: () => { + whatInputRef.current?.focus() + }, + })) + + function handleTypeChange(newType: string) { + setType(newType as any) + // Auto-populate "what" with "cash" when selecting Lent, clear when leaving Lent + if (newType === 'Lent') { + if (!what) { + setWhat('cash') + } + } else if (type === 'Lent' && what === 'cash') { + setWhat('') + } + } async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError(null) setSuccess(false) setLoading(true) + onLoadingChange?.(true) try { await trackExpense({ @@ -31,26 +82,35 @@ export function TrackExpense() { location, }) setSuccess(true) - // Clear form - setWhat('') + // Clear form (but keep "what" for lent expenses) + if (type !== 'Lent') { + setWhat('') + } setLocation('') setPrice('') + // Focus on "what" field for next entry + setTimeout(() => { + whatInputRef.current?.focus() + }, 0) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to track expense') } finally { setLoading(false) + onLoadingChange?.(false) } } return ( -
+
-
+

Track Expense

+ + setType(v as any)} + onTypeChange={handleTypeChange} location={location} onLocationChange={setLocation} price={price} @@ -58,20 +118,12 @@ export function TrackExpense() { currency={currency} onCurrencyChange={setCurrency} loading={loading} + whatInputRef={whatInputRef} /> - -
) -} +}) diff --git a/frontend/src/features/expense/api.ts b/frontend/src/features/expense/api.ts index b4a3fe1..68279a5 100644 --- a/frontend/src/features/expense/api.ts +++ b/frontend/src/features/expense/api.ts @@ -12,6 +12,11 @@ interface ExpenseData { location: string } +export interface User { + id: string + email: string +} + export async function trackExpense(expense: ExpenseData): Promise { const response = await fetch(getApiUrl('/api/track'), { method: 'POST', @@ -24,8 +29,26 @@ export async function trackExpense(expense: ExpenseData): Promise { if (!response.ok) { const error = await response.json() - throw new Error(error.message || 'Failed to track expense') + throw new Error(error.error || error.message || 'Failed to track expense') } return response.json() } + +export async function fetchUsers(): Promise { + const response = await fetch(getApiUrl('/api/users'), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }) + + if (!response.ok) { + throw new Error('Failed to fetch users') + } + + const data = await response.json() + return data.users +} + diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 07763e1..211e903 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -12,3 +12,4 @@ keywords: maintainers: - name: Martin Komischke email: "martin@example.com" + diff --git a/helm/templates/deployment-app.yaml b/helm/templates/deployment-app.yaml index 5f94c5d..f8c686e 100644 --- a/helm/templates/deployment-app.yaml +++ b/helm/templates/deployment-app.yaml @@ -14,6 +14,12 @@ spec: metadata: labels: app: {{ include "split-fairly.fullname" . }}-app + component: app + technology: php + app.kubernetes.io/version: "{{ .Values.env.APP_VERSION }}" + environment: "{{ .Values.env.APP_ENV }}" + annotations: + kubernetes.io/log-format: "json" spec: serviceAccountName: {{ include "split-fairly.fullname" . }} initContainers: diff --git a/helm/templates/deployment-worker.yaml b/helm/templates/deployment-worker.yaml index 505de2d..98dabab 100644 --- a/helm/templates/deployment-worker.yaml +++ b/helm/templates/deployment-worker.yaml @@ -14,8 +14,15 @@ spec: metadata: labels: app: {{ include "split-fairly.fullname" . }}-worker + component: worker + technology: php + app.kubernetes.io/version: "{{ .Values.env.APP_VERSION }}" + environment: "{{ .Values.env.APP_ENV }}" + annotations: + kubernetes.io/log-format: "json" spec: serviceAccountName: {{ include "split-fairly.fullname" . }} + terminationGracePeriodSeconds: 3600 initContainers: - name: wait-for-db-init image: bitnami/kubectl:latest @@ -38,7 +45,20 @@ spec: - name: worker image: "{{ .Values.image.app.repository }}:{{ .Values.image.app.tag }}" imagePullPolicy: {{ .Values.image.app.pullPolicy }} - command: ["bin/console", "messenger:consume", "--all", "-vv"] + # One-shot worker pattern: handle single message per process, then exit + # This prevents memory leaks and improves scalability by using fresh processes + # See: https://symfony.com/doc/current/messenger.html#deploying-messages-to-multiple-transports + command: + - /bin/sh + - -c + - | + while ! [ -f /tmp/kill_me ]; do + bin/console messenger:consume async --limit=1 --time-limit=60 -vv + done + lifecycle: + preStop: + exec: + command: ["sh", "-c", "touch /tmp/kill_me"] env: - name: APP_ENV value: "{{ .Values.env.APP_ENV }}" diff --git a/helm/values.yaml b/helm/values.yaml index 3585fc1..7a64dc0 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -29,7 +29,13 @@ service: resources: app: {} - worker: {} + worker: + limits: + cpu: "250m" + memory: "512Mi" + requests: + cpu: "100m" + memory: "256Mi" web: {} mysql: {} @@ -110,3 +116,8 @@ webConfig: | return 404; } } + +logging: + # Log shipping is handled by Grafana Alloy (k8s-monitoring Helm chart). + # See: helm upgrade grafana-k8s-monitoring grafana/k8s-monitoring +