diff --git a/.env b/.env index 0c16039..aa1f8a9 100644 --- a/.env +++ b/.env @@ -15,6 +15,9 @@ # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration ###> symfony/framework-bundle ### +# NOTE: the demo runtime (app/franken/franken-worker) is forced to APP_ENV=prod + +# MySQL via docker-compose.yml. This file stays on local/SQLite so CI and local dev +# tooling (phpstan, lint:container, etc.) keep working. APP_ENV=local APP_SECRET=2ca64f8d83b9e89f5f19d672841d6bb8 #TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 @@ -26,7 +29,7 @@ APP_SECRET=2ca64f8d83b9e89f5f19d672841d6bb8 # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml # DATABASE_URL=sqlite:///%kernel.project_dir%/data/database.sqlite -# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8&charset=utf8mb4" +# DATABASE_URL="mysql://app:app@database:3306/app?serverVersion=8.0&charset=utf8mb4" # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" ###< doctrine/doctrine-bundle ### diff --git a/.gitignore b/.gitignore index 0e1bdc5..6347f56 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ .idea/ k6/report-* +*.swp +/.phpunit.cache/ diff --git a/Makefile b/Makefile index ac3516c..31ab40d 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ pull-docker: docker pull prom/prometheus:v2.53.4 docker pull grafana/grafana:latest docker pull hipages/php-fpm_exporter:latest - docker pull dunglas/frankenphp:php8.4-bookworm + docker pull dunglas/frankenphp:1.12.4-php8.4-bookworm @echo "All Docker images pulled successfully!" # Individual Docker image pull commands @@ -46,7 +46,7 @@ docker-exporter: docker-frankenphp: @echo "Pulling FrankenPHP image..." - docker pull dunglas/frankenphp + docker pull dunglas/frankenphp:1.12.4-php8.4-bookworm @echo "FrankenPHP image pulled successfully!" build: @@ -120,8 +120,8 @@ franken-shell: worker-shell: docker-compose exec -it franken-worker bash -migrate: ## Run database migrations - docker-compose exec app php bin/console doctrine:migrations:migrate --no-interaction +migrate: ## Create/update the DB schema from entity metadata (DB-agnostic; the committed migrations are SQLite-only) + docker-compose exec app php bin/console doctrine:schema:update --force --complete seed: ## Seed the database with test data @echo "Seeding database with test data..." @@ -130,9 +130,53 @@ seed: ## Seed the database with test data setup: migrate seed ## Run migrations and seed database +test: ## Run the unit test suite (APP_ENV=test) + docker-compose exec -e APP_ENV=test app php bin/phpunit + clean: docker-compose down -v --remove-orphans +# ────────────────────────────────────────────────────────────── +# 🔥 Ember - live FrankenPHP dashboard (see ember.md) +# Ember is the tool from https://github.com/alexandre-daubois/ember +# Install once: brew install alexandre-daubois/tap/ember +# ────────────────────────────────────────────────────────────── +# Defaults target FrankenPHP CLASSIC (:8080 / admin :2019). Classic stays rock-solid +# under load; the worker mode can deadlock when hammered, so it's not the default for +# the live wave. `make ember` + `make ember-load` work as a matched pair out of the box. +EMBER_ADDR ?= http://localhost:2019 +EMBER_TARGETS ?= franken +.PHONY: ember ember-install ember-load compare compare-load open urls + +ember-install: ## 🔥 Install the Ember CLI (auto-detects macOS / Linux / Windows) + @bash bin/install-ember.sh + +ember: ## 🔥 Open the Ember dashboard (classic by default; EMBER_ADDR=...:2020 for the worker) + ember --addr $(EMBER_ADDR) + +ember-load: ## 🔥 Run the up→down→up traffic wave (classic by default; EMBER_TARGETS=worker for the worker) + k6 run -e EMBER_TARGETS=$(EMBER_TARGETS) k6/ember_ramp.js + +compare: ## 🔥 Side-by-side bars for FPM vs FrankenPHP classic vs worker (run alongside 'make compare-load') + docker-compose exec app php bin/scope + +compare-load: ## 🔥 Drive ALL THREE runtimes at once (use this with 'make compare') + k6 run -e EMBER_TARGETS=fpm,franken,worker k6/ember_ramp.js + +# Open the app in the default browser - works on macOS, Linux, WSL and Windows. +URL ?= http://localhost:8088 +open: ## 🌐 Open the app in your default browser (macOS / Linux / WSL / Windows) + @bash bin/open-url.sh "$(URL)" + +urls: ## 🌐 Print all demo URLs (Ctrl/Cmd+click to open) + @echo "FPM app http://localhost:8088" + @echo "FrankenPHP classic http://localhost:8080" + @echo "FrankenPHP worker http://localhost:8081" + @echo "Products (FPM) http://localhost:8088/en/products/db" + @echo "Grafana http://localhost:3000 (symfony/symfony)" + @echo "Prometheus http://localhost:9090" + @echo "OPcache dashboard http://localhost:42042" + # Franken Worker targets .PHONY: k6-franken-worker-products-db k6-franken-worker-products-db: @@ -411,10 +455,17 @@ reset-and-seed: ## help: show available targets help: - @echo "GlasgowPHP CQRS Load Testing" + @echo "Scaling PHP - FrankenPHP demo (see README.md and ember.md)" @echo "" @echo "Available targets:" @echo " help - Show this help message" + @echo "" + @echo "🔥 Ember live demo:" + @echo " ember-install - Install the Ember CLI (macOS / Linux / Windows)" + @echo " ember - Open the Ember dashboard (classic by default)" + @echo " ember-load - Run the up->down->up traffic wave (classic)" + @echo " compare - Side-by-side bars: FPM vs classic vs worker" + @echo " compare-load - Drive ALL THREE runtimes (use with 'compare')" @echo " docker - Pull all required Docker images" @echo " docker-redis - Pull Redis image only" @echo " docker-php - Pull PHP-FPM image only" @@ -423,7 +474,7 @@ help: @echo " docker-exporter - Pull PHP-FPM Exporter image only" @echo " docker-frankenphp - Pull FrankenPHP image only" @echo "" - @echo "Franken Worker (https://localhost:444):" + @echo "Franken Worker (http://localhost:8081):" @echo " k6-franken-worker-products - Test products endpoint" @echo " k6-franken-worker-products-db - Test products DB endpoint" @echo " k6-franken-worker-products-redis - Test products Redis endpoint" @@ -435,7 +486,7 @@ help: @echo " k6-franken-worker-orders-redis - Test orders Redis endpoint" @echo " k6-franken-worker-blog - Test blog endpoint" @echo "" - @echo "Franken (https://localhost:443):" + @echo "Franken (http://localhost:8080):" @echo " k6-franken-products - Test products endpoint" @echo " k6-franken-products-db - Test products DB endpoint" @echo " k6-franken-products-redis - Test products Redis endpoint" diff --git a/README.back.md b/README.back.md deleted file mode 100644 index 9183b12..0000000 --- a/README.back.md +++ /dev/null @@ -1,293 +0,0 @@ -# SymfonyCon 2025 - Scaling PHP Systems - -This project demonstrates a scalable Symfony CQRS application with Redis and DB projections, Docker Compose, and k6 load -testing. All common tasks are managed via the Makefile. - -## Setup - -Before running this project, ensure you have the following dependencies installed: - -Check missing dependencies. - -```bash -./test-system.sh -``` - -### Required Dependencies - -1. **Docker & Docker Compose** - - [Docker Desktop](https://www.docker.com/products/docker-desktop/) (includes Docker Compose) - - Or install separately: [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) - -2. **Make** - - **macOS**: Usually pre-installed, or install via Homebrew: `brew install make` - - **Linux**: Install via package manager: - - Ubuntu/Debian: `sudo apt-get install make` - - CentOS/RHEL: `sudo yum install make` - - Fedora: `sudo dnf install make` - -3. **K6 (Load Testing Tool)** - - **macOS**: `brew install k6` - - **Linux**: - - Ubuntu/Debian: `sudo gpg -k` - ```bash - sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 - echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list - sudo apt-get update - sudo apt-get install k6 - ``` - - CentOS/RHEL/Fedora: `sudo yum install k6` or `sudo dnf install k6` - -### Verify Installation - -Run the test script to verify all dependencies are installed: - -```bash -./test-system.sh -``` - -This will check for Docker, Docker Compose, Make, and K6 and report their status. - -### Docker Images Setup - -Before starting the application, you need to pull the required Docker images. You can do this in two ways: - -**Option 1: Pull all images at once** -```bash -make pull-docker -``` - -**Option 2: Pull individual images** -```bash -# Pull base images -docker pull redis:7-alpine -docker pull php:8.4-fpm - -# Pull additional images as needed -docker pull prom/prometheus -docker pull grafana/grafana -docker pull caddy:2-alpine -``` - -This ensures all required Docker images are available locally before starting the services. - -## Quick Start - -1. **Build and start all services:** - ```bash - make up - ``` - -2. **Set up the database and seed data:** - ```bash - make migrate - make seed - ``` - -Go to http://localhost:8088 - -### opcache introduction - -``` -make up-opcache-dashboard -``` - -open - http://localhost:42042/opcache/status - -https://www.php.net/manual/en/opcache.configuration.php#:~:text=on%20all%20architectures.-,opcache.max_accelerated_files,-int - - -``` -find . -type f -name "*.php" | wc -l - -opcache.max_accelerated_files=16087 - -``` - -### show fpm and opcache dashboard GUI - -show fpm status page - http://localhost:8088/fpm-status - -``` -fpm.conf - pm.status_path = /fpm-status -aa-nginx.conf - location ~ ^/fpm-status$ { - -``` - -**fpm-exporter** -``` -make up-exporter -make ps | grep exporter -``` - -go to http://localhost:9253/metrics - -``` -make up-prometheus -make ps | grep prom -``` - -go to http://localhost:9090/targets?search= - -``` -make up-grafana -make ps | grep grafana -``` - -# show target prom sources - -http://localhost:9090/targets?search= - -# view grafana dashboard - -open http://localhost:3000 -username: croatia -password: croatia - -# show grafana fpm/opcache dashboard - -make k6-fpm-products-db -see k6/report-UTC-xxxxxxx.html -i.e: k6/report-products-db-2025-07-04T16-59-09.331Z.html - -check grafana output - http://localhost:3000 - -see fpm active processes. change to 1m (on left side) -http://localhost:9090/graph?g0.expr=phpfpm_active_processes&g0.tab=0&g0.display_mode=lines&g0.show_exemplars=0&g0.range_input=1m - -**Normal Data Fetch (mysql)** - -show FPM file -show fpm metrics - -- http://localhost:8088/fpm-status -- show FPM calculation logic (in slides) - todo add graphic to this readme. -- show FPM calculator website - https://spot13.com/pmcalculator/ -- find special command to calculate "Stuff", i think cpu thread count, and stuff, - it's inside slides and I think on -- matheus blog - -# run this inside the container, during k6 - - -### check real-time usage of fpm processes - -``` -ps -ylC php-fpm --sort:rss -``` - -### calculate process memory -``` bash -ps --no-headers -o "rss,cmd" -C php-fpm | awk '{ sum+=$1} END { print sum/NR/1024 }' -``` - - -PHP-FPM uses a process manager to handle incoming requests efficiently. The configuration directly affects what you see -in system monitoring tools like `htop`. - -**Key Configuration Settings:** - -```ini -pm = dynamic -pm.max_children = 50 -pm.start_servers = 5 -pm.min_spare_servers = 5 -pm.max_spare_servers = 35 -``` - -**Important:** `pm.start_servers = 5` means you will see **5 child processes** in `htop` when the container starts, plus -1 master process (total 6 PHP-FPM processes). - -### Process Hierarchy in htop - -When you run `htop` or `ps aux | grep php-fpm`, you'll see: - -``` -1 × php-fpm: master process -5 × php-fpm: pool www (child processes) -``` - -**Process Behavior:** - -- **Master Process**: Manages child processes, doesn't handle requests -- **Child Processes**: Handle actual HTTP requests -- **Dynamic Scaling**: Child processes spawn/die based on load (between min_spare_servers and max_spare_servers) - -### Monitoring PHP-FPM Processes - -**View current processes in container:** - -```bash -# Inside container -ps aux | grep php-fpm - -# Or count active processes -ps --no-headers -o "rss,cmd" -C php-fpm | wc -l -``` - -**Calculate average memory usage per process:** - -```bash -# Run this inside the container during k6 testing -ps --no-headers -o "rss,cmd" -C php-fpm | awk '{ sum+=$1} END { print sum/NR/1024 }' -``` - -### Composer Autoload Optimization - -For optimal performance, this project uses Composer autoload optimizations configured in `composer.json`: - -```json -{ - "config": { - "optimize-autoloader": true, - "classmap-authoritative": true - } -} -``` - -**Performance Impact:** - -- `optimize-autoloader`: ~10-15% faster autoloading (converts PSR-0/PSR-4 to classmap) -- `apcu-autoloader`: ~50-70% faster (requires APCu extension) -- `classmap-authoritative`: Set to `false` for development, `true` for production only - -**Reference:** See the -official [Symfony Performance Documentation](https://symfony.com/doc/current/performance.html#optimize-composer-autoloader) -for detailed autoloader optimization guidelines and best practices. - -## Web Interfaces & Dashboards - -| Service | URL | Description | -|-----------------------|-------------------------------|---------------------------------------| -| FPM App | http://localhost:8088 | Main Symfony app (FPM) | -| Franken | http://localhost:8080 | FrankenPHP (HTTP, regular mode) | -| Franken Worker | http://localhost:8081 | FrankenPHP Worker (HTTP, optimized) | -| Grafana | http://localhost:3000 | Metrics dashboard (admin/admin) | -| Prometheus | http://localhost:9090 | Prometheus metrics | -| Opcache Dashboard | http://localhost:42042 | PHP Opcache dashboard | -| Opcache Metrics (FPM) | http://localhost:8088/metrics | PHP Opcache metrics via FPM app | -| Franken Metrics | http://localhost:2019/metrics | Caddy/FrankenPHP metrics (non-worker) | -| Worker Metrics | http://localhost:2020/metrics | Caddy/FrankenPHP metrics (worker) | - -## Grafana Dashboard - -A detailed PHP-FPM and OPcache monitoring dashboard is available in Grafana. It includes: - -- PHP-FPM health, queue, and process metrics -- Request rate, duration, and memory usage -- OPcache hit ratio, memory, and script cache stats -- JIT and interned strings monitoring -- Alerts and color-coded panels for quick health checks - -**See [`grafana-dashboard.md`](grafana-dashboard.md) for a full description of all panels and dashboard features.** - -## FrankenPHP Configuration - -This project uses FrankenPHP (a modern PHP runtime built on Caddy) with two different configurations for performance comparison and monitoring. - -**See [`frankenphp.md`](frankenphp.md) for complete FrankenPHP documentation including:** -- Service configuration and differences -- Auto-reload (file watching) setup -- Caddy configuration and environment variables -- Performance testing and monitoring -- Troubleshooting guide -- Resource optimization guidelines \ No newline at end of file diff --git a/README.back2.md b/README.back2.md deleted file mode 100644 index 59403b7..0000000 --- a/README.back2.md +++ /dev/null @@ -1,389 +0,0 @@ -# SymfonyCon 2025 - Scaling PHP Systems - -This project demonstrates a scalable Symfony CQRS application with Redis and DB projections, Docker Compose, and k6 load -testing. All common tasks are managed via the Makefile. - -## Setup - -### Required Dependencies - -1. **Docker & Docker Compose** - - [Docker Desktop](https://www.docker.com/products/docker-desktop/) (includes Docker Compose) - - Or install separately: [Docker](https://docs.docker.com/get-docker/) - and [Docker Compose](https://docs.docker.com/compose/install/) - -2. **Make** - - **macOS**: Usually pre-installed, or install via Homebrew: `brew install make` - - **Linux**: Install via package manager: - - Ubuntu/Debian: `sudo apt-get install make` - - CentOS/RHEL: `sudo yum install make` - - Fedora: `sudo dnf install make` - -3. **K6 (Load Testing Tool)** - - **macOS**: `brew install k6` - - **Linux**: - - Ubuntu/Debian: `sudo gpg -k` - ```bash - sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 - echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list - sudo apt-get update - sudo apt-get install k6 - ``` - - CentOS/RHEL/Fedora: `sudo yum install k6` or `sudo dnf install k6` - -### Verify Installation - -Run the test script to verify all dependencies are installed: - -```bash -./test-system.sh -``` - -This will check for Docker, Docker Compose, Make, and K6 and report their status. - -### Docker Images Setup - -Before starting the application, you need to pull the required Docker images. You can do this in two ways: - -**Pull all images at once** - -```bash -make pull-docker -``` - -This ensures all required Docker images are available locally before starting the services. - -## Quick Start - -1. **Build and start all services:** - -```bash -make up -``` - -2. **Set up the database and seed data:** - -```bash -make migrate -make seed -``` - -Go to http://localhost:8088 - -### opcache introduction - -``` -make up-opcache-dashboard -``` - -open - http://localhost:42042/opcache/status - -![img.png](docs/images/opcache-dashboard.png) - -https://www.php.net/manual/en/opcache.configuration.php#:~:text=on%20all%20architectures.-,opcache.max_accelerated_files,-int - -``` -find . -type f -name "*.php" | wc -l -``` - -``` -opcache.max_accelerated_files=16087 -``` - -Prime Number: 10000 -> 10007 (nearest prime) - -### show fpm and opcache dashboard GUI - -show fpm status page - http://localhost:8088/fpm-status - -![img.png](docs/images/fpm-status.png) - -``` -fpm.conf - pm.status_path = /fpm-status -aa-nginx.conf - location ~ ^/fpm-status$ { - -``` - -**fpm-exporter** - -``` -make up-exporter -make ps | grep exporter -``` - -Go to http://localhost:9253/metrics - -![img.png](docs/images/fpm-exporter.png) - -``` -make up-prometheus -make ps | grep prom -``` - -Go to http://localhost:9090/targets?search= - -![img.png](docs/images/fpm-exporter.png) - -``` -make up-grafana -make ps | grep grafana -``` - -Go to http://localhost:3000/, choose Dashboard in the sidebar and click `PHP-FPM Performance Dashboard` - -![img.png](docs/images/fpm-grafana.png) - -### Show target prom sources - -http://localhost:9090/targets?search= - -### view grafana dashboard - -open http://localhost:3000 -username: symfony -password: symfony - -### show grafana fpm/opcache dashboard - -```bash -make benchmark-product-random-fpm -``` - -Output: -![PHP-FPM Benchmark Results](docs/images/benchmark-product-random-fpm.png) - -see file inside k6/report-UTC-xxxxxxx.html -> i.e: k6/report-product-by-id-random-8088-2025-11-25T10-45-06.548Z.html - -Check grafana output - http://localhost:3000 - -See fpm active processes. change to 5m (on left side) -http://localhost:3000/d/phpfpm-performance/php-fpm-performance-dashboard?orgId=1&from=now-5m&to=now&timezone=browser&var-datasource=PBFA97CFB590B2093&var-pool=www&refresh=5s - -## PHP-FPM Performance Monitoring & Optimization - -### Accessing FPM Status & Metrics - -**FPM Status Page:** -- URL: http://localhost:8088/fpm-status -- Shows real-time process states, active/idle workers, queue length -- Configured in `fpm.conf` with `pm.status_path = /fpm-status` - -**Prometheus Metrics:** -- URL: http://localhost:9253/metrics (via php-fpm-exporter) -- Scraped by Prometheus for historical data and alerting -- Visualized in Grafana dashboards - -### Right-Sizing PHP-FPM Pool Configuration - -PHP-FPM pool sizing is critical for optimal performance. You need to balance: -- Available RAM -- Expected concurrent requests -- Per-request memory usage -- Response time requirements - -**Configuration Logic:** - -![FPM Pool Sizing](docs/fpm-pool-sizing.png) - -**Formula for `pm.max_children`:** -``` -pm.max_children = (Total Available RAM - RAM for other services) / Average PHP Process Memory -``` - -**Example Calculation:** -``` -Server RAM: 4GB (4096 MB) -System + MySQL: 1GB (1024 MB) -Average PHP process: 50 MB - -pm.max_children = (4096 - 1024) / 50 = 61 processes -``` - -**FPM Calculator Tool:** - -Use the interactive calculator at https://spot13.com/pmcalculator/ to determine optimal settings: - -![FPM Calculator](docs/fpm-calculator.png) - -### Key Configuration Settings - -PHP-FPM uses a process manager to handle incoming requests efficiently. The configuration directly affects what you see in system monitoring tools like `htop`. - -```ini -pm = dynamic # Process manager type (static, dynamic, ondemand) -pm.max_children = 50 # Maximum number of child processes -pm.start_servers = 5 # Number of children created on startup -pm.min_spare_servers = 5 # Minimum idle processes -pm.max_spare_servers = 35 # Maximum idle processes -pm.max_requests = 500 # Requests before process restart (helps with memory leaks) -``` - -**Process Manager Types:** -- `static` - Fixed number of processes (best for consistent load) -- `dynamic` - Scales between min/max spare servers (good for variable load) -- `ondemand` - Creates processes on demand (best for low/intermittent traffic) - -**Important:** `pm.start_servers = 5` means you will see **5 child processes** in `htop` when the container starts, plus 1 master process (total 6 PHP-FPM processes). - -### Process Hierarchy and Behavior - -When you run `htop` or `ps aux | grep php-fpm`, you'll see: - -``` -1 × php-fpm: master process (manages child processes) -5 × php-fpm: pool www (child processes handling requests) -``` - -**Process Roles:** -- **Master Process** - Manages child processes, handles signals, doesn't serve requests -- **Child Processes** - Handle actual HTTP requests from nginx/web server -- **Dynamic Scaling** - Processes spawn/die based on load (between `min_spare_servers` and `max_spare_servers`) - -**Process States:** -- `Idle` - Waiting for requests -- `Running` - Actively processing a request -- `Finishing` - Completing request cleanup -- `Reading headers` - Parsing request headers -- `Ending` - Process is shutting down - -## Monitoring Commands (Run Inside Container During k6 Tests) - -### Check Real-Time Process Usage - -**List processes sorted by memory (RSS):** -```bash -ps -ylC php-fpm --sort:rss -``` - -**Watch process states in real-time:** -```bash -watch -n 1 'ps aux | grep php-fpm' -``` - -**Count active vs idle processes:** -```bash -curl -s http://localhost:8088/fpm-status | grep -E "active|idle" -``` - -### Calculate Process Memory - -**Average memory per process (in MB):** -```bash -ps --no-headers -o "rss,cmd" -C php-fpm | awk '{ sum+=$1} END { print sum/NR/1024 }' -``` - -**Total memory usage by all PHP-FPM processes:** -```bash -ps --no-headers -o "rss,cmd" -C php-fpm | awk '{ sum+=$1 } END { print sum/1024 " MB" }' -``` - -**Memory usage per individual process:** -```bash -ps -o pid,rss,cmd -C php-fpm | awk 'NR>1 {print $1, $2/1024 " MB", $3}' -``` - -### Advanced Monitoring - -**Get CPU usage by PHP-FPM:** -```bash -ps -C php-fpm -o %cpu,pid,cmd --no-headers -``` - -**Find the most memory-intensive PHP-FPM process:** -```bash -ps --no-headers -o "rss,pid,cmd" -C php-fpm | sort -rn | head -5 -``` - -### Understanding the Output - -**RSS (Resident Set Size):** -- Physical memory used by the process -- Shown in KB by default -- Divide by 1024 for MB - -**VSZ (Virtual Memory Size):** -- Total virtual memory allocated -- Usually much larger than RSS -- Includes shared libraries - -**Example Output Interpretation:** -```bash -$ ps -ylC php-fpm --sort:rss - RSS PID CMD -52340 12345 php-fpm: master process -48512 12346 php-fpm: pool www -50240 12347 php-fpm: pool www -``` - -This shows: -- Master process using ~51 MB -- Child processes using ~47-49 MB each -- Total usage: ~150 MB for 3 processes - -### Composer Autoload Optimization - -For optimal performance, this project uses Composer autoload optimizations configured in `composer.json`: - -```json -{ - "config": { - "optimize-autoloader": true, - "classmap-authoritative": true - } -} -``` - -**Performance Impact:** - -- `optimize-autoloader`: ~10-15% faster autoloading (converts PSR-0/PSR-4 to classmap) -- `apcu-autoloader`: ~50-70% faster (requires APCu extension) -- `classmap-authoritative`: Set to `false` for development, `true` for production only - -**Reference:** See the -official [Symfony Performance Documentation](https://symfony.com/doc/current/performance.html#optimize-composer-autoloader) -for detailed autoloader optimization guidelines and best practices. - -## Web Interfaces & Dashboards - -| Service | URL | Description | -|-----------------------|-------------------------------|---------------------------------------| -| FPM App | http://localhost:8088 | Main Symfony app (FPM) | -| Franken | http://localhost:8080 | FrankenPHP (HTTP, regular mode) | -| Franken Worker | http://localhost:8081 | FrankenPHP Worker (HTTP, optimized) | -| Grafana | http://localhost:3000 | Metrics dashboard (admin/admin) | -| Prometheus | http://localhost:9090 | Prometheus metrics | -| Opcache Dashboard | http://localhost:42042 | PHP Opcache dashboard | -| Opcache Metrics (FPM) | http://localhost:8088/metrics | PHP Opcache metrics via FPM app | -| Franken Metrics | http://localhost:2019/metrics | Caddy/FrankenPHP metrics (non-worker) | -| Worker Metrics | http://localhost:2020/metrics | Caddy/FrankenPHP metrics (worker) | - - -## FrankenPHP Configuration - -This project uses FrankenPHP (a modern PHP runtime built on Caddy) with two different configurations for performance -comparison and monitoring. - -**See [`frankenphp.md`](frankenphp.md) for complete FrankenPHP documentation including:** - -- Service configuration and differences -- Auto-reload (file watching) setup -- Caddy configuration and environment variables -- Performance testing and monitoring -- Troubleshooting guide -- Resource optimization guidelines - -## Grafana Dashboard - -A detailed PHP-FPM and OPcache monitoring dashboard is available in Grafana. It includes: - -- PHP-FPM health, queue, and process metrics -- Request rate, duration, and memory usage -- OPcache hit ratio, memory, and script cache stats -- JIT and interned strings monitoring -- Alerts and color-coded panels for quick health checks - -**See [`grafana-dashboard.md`](grafana-dashboard.md) for a full description of all panels and dashboard features.** - - -## Show symfony.prod.ini file \ No newline at end of file diff --git a/README.md b/README.md index fa2663b..5ced65a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,26 @@ -# SymfonyCon 2025 - Scaling PHP Systems +# Scaling PHP Systems - FrankenPHP @ IPC Conference Germany -This project demonstrates a scalable Symfony CQRS application with Redis and DB projections, Docker Compose, and k6 load -testing. All common tasks are managed via the Makefile. +A Symfony CQRS demo for exploring how modern PHP runtimes scale. The same app runs three ways - **PHP-FPM**, +**FrankenPHP classic**, and **FrankenPHP worker** - so you can load-test them side by side with k6 and watch +the difference live. Everything runs in Docker Compose and is driven by the Makefile. + +## What this demo shows + +- One Symfony app (Products / Customers / Orders) using **CQRS**: writes go to the database, reads come from + fast **Redis projections**. +- The same app served by **three runtimes**, so you can compare them fairly: + + | Runtime | Port | What it is | + |--------------------|------|-----------------------------------------------| + | PHP-FPM | 8088 | Traditional process-per-request | + | FrankenPHP classic | 8080 | Modern PHP server on Caddy | + | FrankenPHP worker | 8081 | Long-lived workers, app kept warm in memory | + +- Live metrics for all of them (FPM status, OPcache, Caddy/Prometheus) plus Grafana dashboards. +- **🔥 [Ember](ember.md)** - a one-command live demo that ramps traffic up and down while you watch the + numbers move. + +> Runtimes run **PHP 8.4** (FrankenPHP `1.12.4`). New here? **Start with [ember.md](ember.md).** ## Web Interfaces & Dashboards @@ -82,6 +101,36 @@ make seed Go to http://localhost:8088 +## 🔥 Ember - live demo (start here) + +The quickest way to see what this project is about. We use **[Ember](https://github.com/alexandre-daubois/ember)**, +a terminal dashboard for FrankenPHP, and watch it react to a wave of traffic. + +```bash +# one-time: install the Ember CLI (auto-detects macOS / Linux / Windows) +make ember-install + +# start the app + FrankenPHP, fill the database +make up && make up-franken && make setup +``` + +```bash +# terminal 1 - the live dashboard +make ember +``` + +```bash +# terminal 2 - send a spiky wave of traffic +make ember-load +``` + +Watch RPS and busy threads climb and fall as FrankenPHP handles the wave - no flags needed, both commands +default to the stable classic server. + +Want the **side-by-side** FPM vs classic vs worker race? Use `make compare` + `make compare-load`. + +Full beginner walkthrough (with screenshots) in **[ember.md](ember.md)**. + ## PHP-FPM & OPcache Configuration For detailed end-to-end guides on PHP-FPM and OPcache configuration, monitoring, and optimization: diff --git a/bin/install-ember.sh b/bin/install-ember.sh new file mode 100755 index 0000000..e3db280 --- /dev/null +++ b/bin/install-ember.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Install Ember (https://github.com/alexandre-daubois/ember) - the FrankenPHP +# terminal dashboard. Auto-detects the OS and picks the best install method. +# +# make ember-install (or: bash bin/install-ember.sh) +# +set -e + +REPO="alexandre-daubois/ember" +RELEASES="https://github.com/$REPO/releases" + +# Already installed? Nothing to do. +if command -v ember >/dev/null 2>&1; then + echo "✅ Ember already installed: $(ember version 2>/dev/null | head -1)" + exit 0 +fi + +OS="$(uname -s 2>/dev/null || echo unknown)" +echo "🔍 Detected OS: $OS" + +via_brew() { echo "🍺 Installing via Homebrew..."; brew install "$REPO" 2>/dev/null || brew install alexandre-daubois/tap/ember; } +via_curl() { echo "📥 Installing via the official install script..."; curl -fsSL "https://raw.githubusercontent.com/$REPO/main/install.sh" | sh; } +via_go() { echo "🐹 Installing via 'go install'..."; go install "github.com/$REPO/cmd/ember@latest"; } + +case "$OS" in + Darwin) + if command -v brew >/dev/null 2>&1; then via_brew + elif command -v curl >/dev/null 2>&1; then via_curl + else echo "❌ Need Homebrew or curl. Install one, or grab a binary: $RELEASES"; exit 1 + fi + ;; + Linux) + if command -v brew >/dev/null 2>&1; then via_brew + elif command -v curl >/dev/null 2>&1; then via_curl + elif command -v go >/dev/null 2>&1; then via_go + else echo "❌ Need curl, brew, or go. Install one, or grab a binary: $RELEASES"; exit 1 + fi + ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + echo "🪟 Windows detected." + if command -v scoop >/dev/null 2>&1; then echo "Run: scoop install ember"; exit 1 + elif command -v go >/dev/null 2>&1; then via_go + else echo "Grab the Windows binary from: $RELEASES"; exit 1 + fi + ;; + *) + echo "❓ Unknown OS - trying the universal install script..." + via_curl + ;; +esac + +# macOS Gatekeeper: drop the quarantine flag so it runs first time. +if [ "$OS" = "Darwin" ] && command -v ember >/dev/null 2>&1; then + xattr -d com.apple.quarantine "$(command -v ember)" 2>/dev/null || true +fi + +if command -v ember >/dev/null 2>&1; then + echo "✅ Ember installed: $(ember version 2>/dev/null | head -1)" +else + echo "⚠️ Installed, but 'ember' isn't on your PATH yet - open a new terminal (or add it to PATH)." +fi diff --git a/bin/open-url.sh b/bin/open-url.sh new file mode 100755 index 0000000..1709e40 --- /dev/null +++ b/bin/open-url.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# +# Open a URL in the host's default browser - cross-platform. +# Works on macOS, native Linux, WSL (Windows Subsystem for Linux), and Git Bash. +# If no opener is found (e.g. headless), it just prints a Ctrl+click-able URL. +# +# bash bin/open-url.sh http://localhost:8088 +# make open (opens the app) +# +set -e + +URL="${1:-http://localhost:8088}" + +is_wsl() { + # WSL1/WSL2 both report "microsoft" in /proc/version or /proc/sys/kernel/osrelease, + # and WSL2 sets $WSL_DISTRO_NAME. + [ -n "${WSL_DISTRO_NAME:-}" ] && return 0 + grep -qiE "microsoft|wsl" /proc/version 2>/dev/null && return 0 + grep -qiE "microsoft|wsl" /proc/sys/kernel/osrelease 2>/dev/null && return 0 + return 1 +} + +open_url() { + local url="$1" + + # WSL -> hand off to Windows so the *Windows* default browser opens. + if is_wsl; then + if command -v wslview >/dev/null 2>&1; then wslview "$url"; return; fi + if command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"; return; fi + if command -v cmd.exe >/dev/null 2>&1; then cmd.exe /c start "" "$url"; return; fi + return 1 + fi + + case "$(uname -s 2>/dev/null)" in + Darwin) + open "$url" # macOS + ;; + Linux) + command -v xdg-open >/dev/null 2>&1 && xdg-open "$url" && return 0 + command -v gio >/dev/null 2>&1 && gio open "$url" && return 0 + return 1 + ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + start "" "$url" # Git Bash / MSYS on Windows + ;; + *) + return 1 + ;; + esac +} + +if open_url "$URL" >/dev/null 2>&1; then + env_label="$(is_wsl && echo 'WSL' || uname -s)" + echo "🌐 Opened in your default browser ($env_label): $URL" +else + # Fallback: most modern terminals make this Ctrl/Cmd+clickable. + echo "🌐 Open this in your browser: $URL" +fi diff --git a/bin/scope b/bin/scope new file mode 100755 index 0000000..25569f6 --- /dev/null +++ b/bin/scope @@ -0,0 +1,280 @@ +#!/usr/bin/env php + ['label' => 'FPM (8088)', 'status' => 'http://localhost:8088/fpm-status?full&json', 'opcache' => 'http://localhost:8088/opcache-stats'], + 'franken' => ['label' => 'FRANKEN (8080)', 'metrics' => 'http://localhost:2019/metrics', 'opcache' => 'http://localhost:8080/opcache-stats'], + 'worker' => ['label' => 'WORKER (8081)', 'metrics' => 'http://localhost:2020/metrics', 'opcache' => 'http://localhost:8081/opcache-stats'], + ] + : [ + 'fpm' => ['label' => 'FPM (8088)', 'status' => 'http://localhost/fpm-status?full&json', 'opcache' => 'http://localhost/opcache-stats'], + 'franken' => ['label' => 'FRANKEN (8080)', 'metrics' => 'http://franken:2019/metrics', 'opcache' => 'http://franken/opcache-stats'], + 'worker' => ['label' => 'WORKER (8081)', 'metrics' => 'http://franken-worker:2019/metrics', 'opcache' => 'http://franken-worker/opcache-stats'], + ]; + +// Rows we draw. Each row knows which runtimes it applies to; the rest show "-". +$rows = [ + 'reqps' => ['title' => 'req/sec', 'for' => ['fpm', 'franken', 'worker']], + 'busy' => ['title' => 'busy threads', 'for' => ['franken', 'worker']], + 'active' => ['title' => 'FPM active', 'for' => ['fpm']], + 'opcache' => ['title' => 'opcache hit', 'for' => ['fpm', 'franken', 'worker']], +]; + +// Keep the cursor sane even if the user hits Ctrl-C. +register_shutdown_function(static fn () => print "\033[?25h\n"); +if (\function_exists('pcntl_async_signals')) { + pcntl_async_signals(true); + pcntl_signal(\SIGINT, static function (): void { exit(0); }); + pcntl_signal(\SIGTERM, static function (): void { exit(0); }); +} + +$prev = []; // previous counter snapshots, for per-second rates +$peak = []; // running max per "runtime.row", so bars auto-scale to the wave +$lastTotalReqps = 0.0; + +print "\033[2J\033[?25l"; // clear screen, hide cursor + +while (true) { + $now = microtime(true); + $data = []; + foreach ($targets as $key => $t) { + $data[$key] = poll($key, $t, $prev, $now); + } + + render($targets, $rows, $data, $peak, $lastTotalReqps); + + if ($once) { + break; + } + + $lastTotalReqps = array_sum(array_map(static fn ($d) => $d['reqps'] ?? 0, $data)); + usleep((int) ($interval * 1_000_000)); +} + +/** + * Fetch one runtime and shape its numbers. Anything unreachable comes back + * offline rather than blowing up the whole dashboard. + */ +function poll(string $key, array $t, array &$prev, float $now): array +{ + $out = ['online' => false, 'reqps' => 0.0, 'busy' => null, 'threads' => null, 'active' => null, 'opcache' => null]; + + if (isset($t['status'])) { + // PHP-FPM: status JSON gives us active processes + a request counter. + $json = http_get($t['status']); + if (null !== $json && ($s = json_decode($json, true)) && isset($s['active processes'])) { + $out['online'] = true; + $out['active'] = (int) $s['active processes']; + $out['threads'] = (int) ($s['total processes'] ?? 0); + $out['reqps'] = rate($key, (float) ($s['accepted conn'] ?? 0), $now, $prev); + } + } + + if (isset($t['metrics'])) { + // FrankenPHP: Caddy/Prometheus text gives busy/total threads + a request counter. + $body = http_get($t['metrics']); + if (null !== $body) { + $out['online'] = true; + $out['busy'] = (int) prom_sum($body, 'frankenphp_busy_threads'); + $out['threads'] = (int) prom_sum($body, 'frankenphp_total_threads'); + $reqs = prom_sum($body, 'caddy_http_requests_total'); + if (0.0 === $reqs) { + $reqs = prom_sum($body, 'caddy_http_request_duration_seconds_count'); + } + $out['reqps'] = rate($key, $reqs, $now, $prev); + } + } + + if (isset($t['opcache'])) { + $json = http_get($t['opcache']); + if (null !== $json && ($o = json_decode($json, true)) && isset($o['opcache_statistics']['opcache_hit_rate'])) { + $out['opcache'] = (float) $o['opcache_statistics']['opcache_hit_rate']; + } + } + + return $out; +} + +/** Per-second rate from a monotonic counter, remembering the last sample per runtime. */ +function rate(string $key, float $total, float $now, array &$prev): float +{ + $rps = 0.0; + if (isset($prev[$key])) { + [$pt, $pv] = $prev[$key]; + $dt = $now - $pt; + if ($dt > 0 && $total >= $pv) { + $rps = ($total - $pv) / $dt; + } + } + $prev[$key] = [$now, $total]; + + return $rps; +} + +/** Sum every Prometheus sample whose name matches $metric (ignoring labels). */ +function prom_sum(string $body, string $metric): float +{ + $sum = 0.0; + foreach (explode("\n", $body) as $line) { + if ('' === $line || '#' === $line[0]) { + continue; + } + if (preg_match('/^'.preg_quote($metric, '/').'(\{[^}]*\})?\s+([0-9.eE+-]+)/', $line, $m)) { + $sum += (float) $m[2]; + } + } + + return $sum; +} + +/** Tiny, fast HTTP GET. Returns the body or null on any trouble. */ +function http_get(string $url): ?string +{ + $ctx = stream_context_create(['http' => [ + 'timeout' => 1.5, + 'ignore_errors' => true, + 'follow_location' => 1, + ]]); + $body = @file_get_contents($url, false, $ctx); + + return false === $body ? null : $body; +} + +/** Repaint the whole dashboard in place. */ +function render(array $targets, array $rows, array $data, array &$peak, float $lastTotalReqps): void +{ + $keys = array_keys($targets); + $colW = 19; + $labelW = 13; + + $out = "\033[H"; // cursor home - overwrite the previous frame + $out .= dim('🔥 SCOPE - FPM vs FrankenPHP classic vs worker, live')."\n"; + $out .= dim(str_repeat('─', $labelW + \count($keys) * $colW))."\n"; + + // Column headers + $out .= str_pad('', $labelW); + foreach ($keys as $key) { + $online = $data[$key]['online']; + $head = $targets[$key]['label'].($online ? '' : ' off'); + $out .= ($online ? "\033[1m" : "\033[2m").str_pad($head, $colW)."\033[0m"; + } + $out .= "\n"; + + foreach ($rows as $rowKey => $row) { + $out .= str_pad($row['title'], $labelW); + foreach ($keys as $key) { + if (!\in_array($key, $row['for'], true)) { + $out .= dim(str_pad(' -', $colW)); + continue; + } + $out .= cell($rowKey, $key, $data[$key], $peak, $colW); + } + $out .= "\n"; + } + + // Footer: which way the fire is going. + $total = array_sum(array_map(static fn ($d) => $d['reqps'] ?? 0, $data)); + if ($total < 5) { + $hint = dim('• idle - start traffic with `make ember-load`'); + } elseif ($total > $lastTotalReqps * 1.05) { + $hint = "\033[38;5;208m↑ traffic ramping up...\033[0m"; + } elseif ($total < $lastTotalReqps * 0.95) { + $hint = "\033[38;5;39m↓ traffic cooling down...\033[0m"; + } else { + $hint = "\033[38;5;196m■ holding steady\033[0m"; + } + $out .= "\n".$hint.dim(' (ctrl-c to quit)')."\033[K\n"; + + print $out; +} + +/** Render one runtime's value for one row: a colored bar + the number. */ +function cell(string $rowKey, string $key, array $d, array &$peak, int $colW): string +{ + if (!$d['online']) { + return dim(str_pad(' offline', $colW)); + } + + [$value, $scale, $text] = measure($rowKey, $d); + if (null === $value) { + return dim(str_pad(' -', $colW)); + } + + // req/sec auto-scales to the tallest flame we've seen on this runtime so the + // wave stays readable; bounded rows (threads, %) use their natural ceiling. + if (null === $scale) { + $pk = $peak[$key.'.'.$rowKey] ?? 1.0; + $scale = $peak[$key.'.'.$rowKey] = max($pk, $value, 1.0); + } + + $frac = $scale > 0 ? min(1.0, $value / $scale) : 0.0; + $filled = (int) round($frac * BAR_WIDTH); + $bar = str_repeat(BAR_FILL, $filled).str_repeat(BAR_EMPTY, BAR_WIDTH - $filled); + + return ember($frac).$bar."\033[0m ".str_pad($text, $colW - BAR_WIDTH - 1); +} + +/** Pull the right value + scale + printable text for a row out of a runtime's data. */ +function measure(string $rowKey, array $d): array +{ + return match ($rowKey) { + 'reqps' => [$d['reqps'], null, fmt($d['reqps'])], + 'busy' => null === $d['busy'] ? [null, 1, ''] : [(float) $d['busy'], max(1, (int) $d['threads']), $d['busy'].'/'.$d['threads']], + 'active' => null === $d['active'] ? [null, 1, ''] : [(float) $d['active'], max(1, (int) $d['threads']), (string) $d['active']], + 'opcache' => null === $d['opcache'] ? [null, 1, ''] : [$d['opcache'], 100.0, number_format($d['opcache'], 1).'%'], + default => [null, 1, ''], + }; +} + +/** Colour ramp: cold blue → yellow → orange → white-hot red as the bar fills. */ +function ember(float $frac): string +{ + $code = match (true) { + $frac < 0.10 => 244, // dim grey (almost idle) + $frac < 0.30 => 39, // blue + $frac < 0.50 => 220, // yellow + $frac < 0.75 => 208, // orange + default => 196, // red hot + }; + + return "\033[38;5;{$code}m"; +} + +function fmt(float $n): string +{ + return $n >= 1000 ? number_format($n / 1000, 1).'k' : (string) (int) round($n); +} + +function dim(string $s): string +{ + return "\033[2m".$s."\033[0m"; +} diff --git a/composer.json b/composer.json index 9e6ba6f..5f2ffb8 100644 --- a/composer.json +++ b/composer.json @@ -1,4 +1,6 @@ { + "name": "dragoonis/scaling-php", + "description": "Scaling PHP demo project showcasing CQRS, projections, and deployment topologies with Symfony.", "license": "proprietary", "type": "project", "minimum-stability": "stable", @@ -19,8 +21,8 @@ "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^3.0", "league/commonmark": "^2.1", - "predis/predis": "*", - "runtime/frankenphp-symfony": "*", + "predis/predis": "^3.3", + "runtime/frankenphp-symfony": "^0.2", "symfony/apache-pack": "^1.0", "symfony/asset": "7.3.*", "symfony/asset-mapper": "7.3.*", diff --git a/composer.lock b/composer.lock index 22e047c..5d38e43 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4025b3cf590c47b4cd71899c3c9f1f92", + "content-hash": "83cc8b27ddae03a3e7393fac3544340f", "packages": [ { "name": "composer/semver", @@ -1344,16 +1344,16 @@ }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -1378,9 +1378,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -1447,7 +1447,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -1885,16 +1885,16 @@ }, { "name": "nette/schema", - "version": "v1.3.3", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", - "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "shasum": "" }, "require": { @@ -1902,8 +1902,10 @@ "php": "8.1 - 8.5" }, "require-dev": { - "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^2.0@stable", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -1944,26 +1946,26 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.3" + "source": "https://github.com/nette/schema/tree/v1.3.5" }, - "time": "2025-10-30T22:57:59+00:00" + "time": "2026-02-23T03:47:12+00:00" }, { "name": "nette/utils", - "version": "v4.0.9", + "version": "v4.1.3", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "505a30ad386daa5211f08a318e47015b501cad30" + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/505a30ad386daa5211f08a318e47015b501cad30", - "reference": "505a30ad386daa5211f08a318e47015b501cad30", + "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", + "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", "shasum": "" }, "require": { - "php": "8.0 - 8.5" + "php": "8.2 - 8.5" }, "conflict": { "nette/finder": "<3", @@ -1971,8 +1973,10 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -1986,7 +1990,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "4.1-dev" } }, "autoload": { @@ -2033,9 +2037,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.9" + "source": "https://github.com/nette/utils/tree/v4.1.3" }, - "time": "2025-10-31T00:45:47+00:00" + "time": "2026-02-13T03:05:33+00:00" }, { "name": "predis/predis", @@ -6108,16 +6112,16 @@ }, { "name": "symfony/process", - "version": "v7.3.4", + "version": "v7.3.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" + "reference": "81fe4ea2c3b8677fa2adfd8e48ba42374ede0e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", - "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", + "url": "https://api.github.com/repos/symfony/process/zipball/81fe4ea2c3b8677fa2adfd8e48ba42374ede0e3b", + "reference": "81fe4ea2c3b8677fa2adfd8e48ba42374ede0e3b", "shasum": "" }, "require": { @@ -6149,7 +6153,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.4" + "source": "https://github.com/symfony/process/tree/v7.3.11" }, "funding": [ { @@ -6169,7 +6173,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:12:26+00:00" + "time": "2026-01-26T13:14:40+00:00" }, { "name": "symfony/property-access", diff --git a/data/database.sqlite b/data/database.sqlite index d5f7156..be57555 100644 Binary files a/data/database.sqlite and b/data/database.sqlite differ diff --git a/data/database_test.sqlite b/data/database_test.sqlite index 1bdf92e..9bb5515 100644 Binary files a/data/database_test.sqlite and b/data/database_test.sqlite differ diff --git a/docker-compose.yml b/docker-compose.yml index 0efe87b..00224bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,9 +8,15 @@ services: - "8088:80" env_file: - .env + environment: + APP_ENV: prod + DATABASE_URL: "mysql://app:app@database:3306/app?serverVersion=8.0&charset=utf8mb4" volumes: - .:/var/www/html - ./docker/fpm.conf:/usr/local/etc/php-fpm.d/zz-docker.conf + depends_on: + database: + condition: service_healthy networks: - app-net @@ -28,7 +34,12 @@ services: env_file: - .env environment: + APP_ENV: prod + DATABASE_URL: "mysql://app:app@database:3306/app?serverVersion=8.0&charset=utf8mb4" SERVER_NAME: ":80" + depends_on: + database: + condition: service_healthy networks: - app-net @@ -49,7 +60,12 @@ services: - .env environment: APP_RUNTIME: "Runtime\\FrankenPhpSymfony\\Runtime" + APP_ENV: prod + DATABASE_URL: "mysql://app:app@database:3306/app?serverVersion=8.0&charset=utf8mb4" SERVER_NAME: ":80" + depends_on: + database: + condition: service_healthy networks: - app-net @@ -112,9 +128,29 @@ services: networks: - app-net + database: + image: mysql:8.0 + environment: + MYSQL_DATABASE: app + MYSQL_USER: app + MYSQL_PASSWORD: app + MYSQL_ROOT_PASSWORD: app + ports: + - "3306:3306" + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-papp"] + interval: 5s + timeout: 5s + retries: 20 + networks: + - app-net + volumes: caddy_data: caddy_config: + db_data: networks: app-net: diff --git a/docker/Caddyfile b/docker/Caddyfile index 2c8e8f7..8b1cf6f 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -23,7 +23,10 @@ {$SERVER_NAME::80} { root {$SERVER_ROOT:public/} - encode zstd br gzip + encode zstd br gzip + + # Access logging - fills Ember's "Logs > Access" panel with a line per request. + log {$CADDY_SERVER_EXTRA_DIRECTIVES} diff --git a/docker/Caddyfile.regular b/docker/Caddyfile.regular index 0a31cfa..5e53a2f 100644 --- a/docker/Caddyfile.regular +++ b/docker/Caddyfile.regular @@ -10,7 +10,10 @@ {$SERVER_NAME::80} { root {$SERVER_ROOT:public/} - encode zstd br gzip + encode zstd br gzip + + # Access logging - fills Ember's "Logs > Access" panel with a line per request. + log {$CADDY_SERVER_EXTRA_DIRECTIVES} diff --git a/docker/Dockerfile.franken b/docker/Dockerfile.franken index 13fb5ee..1c42172 100644 --- a/docker/Dockerfile.franken +++ b/docker/Dockerfile.franken @@ -1,4 +1,4 @@ -FROM dunglas/frankenphp:php8.4-bookworm +FROM dunglas/frankenphp:1.12.4-php8.4-bookworm WORKDIR /var/www/html @@ -6,7 +6,8 @@ RUN install-php-extensions \ gd \ intl \ zip \ - opcache + opcache \ + pdo_mysql COPY --chown=www-data:www-data ./docker/symfony.prod.ini $PHP_INI_DIR/php.ini diff --git a/docs/images/ember-01-products.png b/docs/images/ember-01-products.png new file mode 100644 index 0000000..9e44f09 Binary files /dev/null and b/docs/images/ember-01-products.png differ diff --git a/docs/images/ember-02-version.png b/docs/images/ember-02-version.png new file mode 100644 index 0000000..63735ca Binary files /dev/null and b/docs/images/ember-02-version.png differ diff --git a/docs/images/ember-03-open.png b/docs/images/ember-03-open.png new file mode 100644 index 0000000..65cefaa Binary files /dev/null and b/docs/images/ember-03-open.png differ diff --git a/docs/images/ember-04-threads.png b/docs/images/ember-04-threads.png new file mode 100644 index 0000000..82a6906 Binary files /dev/null and b/docs/images/ember-04-threads.png differ diff --git a/docs/images/ember-05-wave.png b/docs/images/ember-05-wave.png new file mode 100644 index 0000000..250fd76 Binary files /dev/null and b/docs/images/ember-05-wave.png differ diff --git a/docs/images/ember-06-live.png b/docs/images/ember-06-live.png new file mode 100644 index 0000000..71e0069 Binary files /dev/null and b/docs/images/ember-06-live.png differ diff --git a/docs/images/ember-07-graphs.png b/docs/images/ember-07-graphs.png new file mode 100644 index 0000000..f1e4984 Binary files /dev/null and b/docs/images/ember-07-graphs.png differ diff --git a/docs/images/ember-08-detail.png b/docs/images/ember-08-detail.png new file mode 100644 index 0000000..97b20b3 Binary files /dev/null and b/docs/images/ember-08-detail.png differ diff --git a/docs/images/ember-09-compare.png b/docs/images/ember-09-compare.png new file mode 100644 index 0000000..4a5c2b7 Binary files /dev/null and b/docs/images/ember-09-compare.png differ diff --git a/docs/images/ember-host-detail.png b/docs/images/ember-host-detail.png new file mode 100644 index 0000000..0cc757d Binary files /dev/null and b/docs/images/ember-host-detail.png differ diff --git a/docs/images/ember-tab-caddy.png b/docs/images/ember-tab-caddy.png new file mode 100644 index 0000000..7d70313 Binary files /dev/null and b/docs/images/ember-tab-caddy.png differ diff --git a/docs/images/ember-tab-certs.png b/docs/images/ember-tab-certs.png new file mode 100644 index 0000000..ba858f6 Binary files /dev/null and b/docs/images/ember-tab-certs.png differ diff --git a/docs/images/ember-tab-config.png b/docs/images/ember-tab-config.png new file mode 100644 index 0000000..7a4b877 Binary files /dev/null and b/docs/images/ember-tab-config.png differ diff --git a/docs/images/ember-tab-logs.png b/docs/images/ember-tab-logs.png new file mode 100644 index 0000000..244596a Binary files /dev/null and b/docs/images/ember-tab-logs.png differ diff --git a/ember.md b/ember.md new file mode 100644 index 0000000..4f94081 --- /dev/null +++ b/ember.md @@ -0,0 +1,299 @@ +# 🔥 Ember - Watch FrankenPHP Work, Live + +This is a follow-along guide. If you can copy and paste, you can do the whole thing. We'll go from +**nothing** to **watching a live dashboard light up** as fake visitors flood the server. 😎 + +> **Ember** is a free tool that shows you, in your terminal, what a FrankenPHP server is doing *right now*. +> It's made by Alexandre Daubois - see https://github.com/alexandre-daubois/ember + +> 📸 **About the screenshots:** every step below has a picture so you can check you're on track. Save your +> own captures into `docs/images/` using the filename shown under each step and they'll appear here. + +--- + +## The big idea (in plain words) + +Think of our web server as a **restaurant kitchen**: + +- Every visitor to the website is a **customer** placing an order. +- FrankenPHP has a team of **cooks** (it calls them *threads* and *workers*). +- When lots of customers arrive, more cooks get busy. When it's quiet, the cooks wait around. + +**Ember is a little window into that kitchen.** It shows how many customers are arriving, how many cooks +are busy, and how fast orders are coming out - and it updates every second. + +By the end of this guide you'll send a big **wave of customers** at the kitchen and watch the numbers in +Ember climb up, fall down, and climb again. That's the whole show. 🔥 + +--- + +## What you need first + +1. **The app running** (our FrankenPHP "worker" kitchen). +2. **The `ember` tool** installed on your computer. + +Let's get both. + +--- + +## Step 1 - Start the kitchen + +Open a terminal in this project and run these three lines: + +```bash +make up # start the app + database helpers +make setup # fill the database with ~10,000 products +make up-worker # start the FrankenPHP worker (our star server) +``` + +Now check the kitchen is open. Open this in your browser: + +- http://localhost:8081/en/products/db + +> 💡 On **WSL / Windows / Linux** where Ctrl+click might not open it, run `make open URL=http://localhost:8081/en/products/db` +> (it detects WSL and opens your real Windows browser). `make urls` prints every demo URL to click. + +We notice that we get a big list of products. The kitchen is cooking. ✅ + +![Step 1 - the worker returns a list of products in the browser](docs/images/ember-01-products.png) +> 📸 *Capture: the browser showing the products JSON at `localhost:8081`. Save as `docs/images/ember-01-products.png`.* + +--- + +## Step 2 - Get the Ember tool + +You only do this once. One command, works on **macOS, Linux, and Windows** (it auto-detects your OS and +picks Homebrew, the install script, or `go install`): + +```bash +make ember-install +``` + +Check it's there: + +```bash +ember version +``` + +> Prefer to do it by hand on a Mac? `brew install alexandre-daubois/tap/ember`. If macOS blocks it the +> first time, run `xattr -d com.apple.quarantine $(which ember)` and try again (the installer does this +> for you automatically). + +![Step 2 - ember version prints the installed version](docs/images/ember-02-version.png) +> 📸 *Capture: the terminal after `ember version`. Save as `docs/images/ember-02-version.png`.* + +--- + +## Step 3 - Open the Ember window + +In your **first terminal**, run: + +```bash +make ember +``` + +That opens a live dashboard watching **FrankenPHP classic** (`http://localhost:8080`, the steady server we +load in this demo, 12 threads). Right now the kitchen is quiet, so most numbers are small (you'll see +`RPS 0` - that's normal until we send traffic in Step 4). + +> Want to watch the **worker** instead (20 threads, the fastest mode)? Run `make ember EMBER_ADDR=http://localhost:2020`. + +![Step 3 - the Ember dashboard opens, quiet (RPS 0)](docs/images/ember-03-open.png) +> 📸 *Capture: the dashboard right after it opens. Save as `docs/images/ember-03-open.png`.* + +### How to move around Ember (say this to the audience) + +**Ember has no mouse - you drive it with the keyboard.** These are the only keys you need: + +| Press this | And it does… | +|-------------------|--------------------------------------------------------------------| +| **`Tab`** | Move to the next **tab** at the top (Caddy → FrankenPHP → …). | +| **`1` … `9`** | Jump straight to a tab by number. | +| **`↑` / `↓`** (or `j` / `k`) | Move up and down a list. | +| **`Enter`** | Open a **detail** view for the highlighted row. | +| **`g`** | Big **full-screen graphs** (the crowd-pleaser). | +| **`p`** | **Pause** / resume the live updates. | +| **`?`** | Show the help screen with every key. | +| **`q`** | **Quit** Ember. | + +👉 **Do this now:** press **`Tab`** once to land on the **`FrankenPHP (12 threads)`** tab. That's our demo +view - it lists all 12 cooks. (If you point Ember at the worker it'll say 20 threads - same idea.) The first +**`[Caddy]`** tab's "Host" list stays empty in this demo because our server has no website name - that's +expected, just ignore it. + +![Step 3 - the FrankenPHP threads tab listing every thread](docs/images/ember-04-threads.png) +> 📸 *Capture: the `FrankenPHP (… threads)` tab with the thread list. Save as `docs/images/ember-04-threads.png`.* + +Here's what the numbers mean, in plain words: + +| On screen | What it really means (kid version) | +|------------------|---------------------------------------------------------------------------------| +| **RPS** ⭐ | Customers arriving **per second**. This is the big one - watch it climb. | +| **CPU** ⭐ | How hard the kitchen is working. Climbs with the rush. | +| **2xx/s** ⭐ | **Happy** orders served per second (green). Tracks the wave up and down. | +| **Avg / P99** | How long an order takes. **P99** = "almost the slowest" - 99 of 100 were faster.| +| **Threads busy / idle** | Cooks **working** right now vs cooks **waiting**. | +| **In-flight / Queue** | Orders cooking right now / customers waiting in line. | +| **4xx / 5xx/s** | **Problem** orders (red). We want these near zero. | +| **RSS** | How much memory the kitchen is using. | + +> The three ⭐ numbers (**RPS, CPU, 2xx/s**) are the ones that move the most - point the audience at those. +> Leave this terminal open and big on the screen. + +--- + +## Step 4 - Send a wave of customers + +Open a **second terminal** right next to the first one, and run: + +```bash +make ember-load +``` + +That's it - no flags. It sends a long, realistic **wave** of visitors at the same classic server your +Ember is watching (about 2.5 minutes). The wave goes: + +1. **Up** - more and more customers arrive (lunch rush starts) 📈 +2. A short **busy plateau** +3. **Down** - the rush calms (people go back to work) 📉 +4. **Up again, even higher** - a second, bigger rush 📈📈 +5. **Empty** - everyone goes home + +You'll see the load tool print its own progress bars, and within a few seconds **RPS climbs in the Ember +window** (terminal 1). Zero failures - it just works. + +![Step 4 - k6 ramping virtual users up and down in the second terminal](docs/images/ember-05-wave.png) +> 📸 *Capture: the k6 output with the ramping VUs. Save as `docs/images/ember-05-wave.png`.* + +--- + +## Step 5 - Watch the numbers go up and down 🔥 + +Now look back at the Ember window. Press **`g`** for the full-screen graphs - the up-and-down is clearest +there. As the wave grows: + +> Still seeing all zeros? Your wave isn't running - go back to Step 4 and start `make ember-load`. + +- **RPS climbs** - more customers per second (you'll see it go from ~1 to well over 100). +- **CPU climbs** with it - the kitchen working harder. +- **2xx/s** (the green, happy number) tracks the wave up and down. + +When the wave eases, all three **cool back down**. When the second, bigger wave hits, they climb again. +That up–down–up is the heartbeat of a real website. 💓 + +![Step 5 - Ember header with RPS and CPU climbing under load](docs/images/ember-06-live.png) +> 📸 *Capture: the dashboard while the wave is busy (RPS/CPU high). Save as `docs/images/ember-06-live.png`.* + +![Step 5 - full-screen graphs (press g) showing the wave](docs/images/ember-07-graphs.png) +> 📸 *Capture: press `g`, then screenshot the graphs as the wave moves. Save as `docs/images/ember-07-graphs.png`.* + +**The punchline for the talk:** even while RPS rockets, the **queue stays tiny** and the cooks barely look +busy. The worker is so fast that customers almost never wait. *That calmness under a crowd is exactly why +FrankenPHP worker mode is special* - say that out loud when the second wave peaks. 🏆 + +--- + +## Step 6 - Fun things to press while it runs + +Ember is interactive. While it's open, try: + +| Key | What it does | +|------------|---------------------------------------------------------| +| `g` | **Full-screen graphs** - beautiful for the audience. | +| `p` | **Pause / resume** the live updates. | +| `Tab` / `1`–`9` | Switch tabs (Caddy hosts, FrankenPHP threads, …). | +| `Enter` | Open a **detail** panel for the selected row. | +| `r` | **Restart** the FrankenPHP workers (watch them recover).| +| `?` | Help overlay (all the keys). | +| `q` | Quit. | + +Tip for the talk: hit **`g`** right when the second wave climbs - the graphs make the "up and down" obvious +from the back of the room. + +![Step 6 - a thread detail panel (press Enter on a row)](docs/images/ember-08-detail.png) +> 📸 *Capture: press `Enter` on a thread to open its detail panel. Save as `docs/images/ember-08-detail.png`.* + +When you're done, press **`q`** to quit Ember, and **Ctrl-C** in the other terminal to stop the wave. + +### The other tabs + +Press **`Tab`** to cycle through the rest. They're handy to show off live: + +**Caddy Config** - the whole server config as a tree (press `Enter` to expand): + +![Ember - Caddy Config tab](docs/images/ember-tab-config.png) + +**Certificates** - the TLS certs Caddy is managing, with expiry days: + +![Ember - Certificates tab](docs/images/ember-tab-certs.png) + +**Logs** - Caddy's Runtime log (server startup / errors): + +![Ember - Logs tab](docs/images/ember-tab-logs.png) + +--- + +## Bonus - see all three runtimes at once (`make compare`) + +Ember zooms into one FrankenPHP server. If you want the **side-by-side** view - PHP-FPM vs FrankenPHP +classic vs the worker, all reacting to the same wave - use the little companion that ships with this repo: + +```bash +# terminal 1 +make compare +# terminal 2 - drives ALL THREE at once +make compare-load +``` + +> ⚠️ Use **`make compare-load`** here, not `make ember-load`. Plain `make ember-load` only hits classic, +> so the FPM and worker bars would sit near zero. `make compare-load` hits all three. + +`make compare` draws one glowing bar per runtime, so the audience can see the **worker's req/sec rocket past +the other two** in a single screen. (Press Ctrl-C to quit.) + +![Bonus - make compare: side-by-side bars for FPM, classic, and worker](docs/images/ember-09-compare.png) +> 📸 *Capture: `make compare` while the wave runs. Save as `docs/images/ember-09-compare.png`.* + +--- + +## Want to watch a different server? + +By default `make ember` watches the **worker** (`http://localhost:2020`). You can point Ember anywhere: + +```bash +ember --addr http://localhost:2019 # the "classic" FrankenPHP (port 8080) +ember --addr http://localhost:2020 # the worker (port 8081) - the default +``` + +And you can aim the traffic wave at one or more servers: + +```bash +EMBER_TARGETS=worker make ember-load # just the worker (what this guide uses) +EMBER_TARGETS=fpm,franken,worker make ember-load # all three at once +``` + +--- + +## Troubleshooting + +**`make ember` says it can't connect / "Caddy unreachable".** +The worker isn't up. Start it and try again: + +```bash +make up-worker +ember status --addr http://localhost:2020 # quick one-line health check +``` + +**`ember: command not found`.** +You skipped Step 2. Install it with `brew install alexandre-daubois/tap/ember`. + +**A server returns PHP errors (e.g. `Call to undefined function ...`) right after first setup.** +That server started before `composer install` finished, so its memory cache is stale. Give it a clean +restart: + +```bash +docker compose up -d --force-recreate franken-worker # or franken / app +``` + +**The wave says it couldn't load product IDs.** +The database isn't filled yet. Run `make setup` and try again. diff --git a/k6/ember_ramp.js b/k6/ember_ramp.js new file mode 100644 index 0000000..b081604 --- /dev/null +++ b/k6/ember_ramp.js @@ -0,0 +1,94 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +/* + * 🔥 Ember load wave. + * + * One long-running scenario that ramps virtual users UP, then DOWN, then UP + * higher, then drains away - a real-ish wave of traffic. It drives all three + * runtimes at once (FPM, FrankenPHP classic, FrankenPHP worker) so you can watch + * them react side by side in `make ember`. + * + * make ember-load # all three runtimes + * EMBER_TARGETS=worker make ember-load # just the worker + * EMBER_TARGETS=fpm,worker make ember-load # pick a couple + * + * Run this in one terminal and `make ember` in another. + */ + +const TARGETS = { + fpm: 'http://localhost:8088', + franken: 'http://localhost:8080', + worker: 'http://localhost:8081', +}; + +// The wave. Same shape for every runtime so the comparison is fair. +// The app MUST run in prod mode (APP_ENV=prod) - in dev mode FrankenPHP's threads deadlock +// rebuilding the cache. With prod, classic happily handles a few hundred RPS. We keep a low +// VU count with sharp up/down bursts so the Ember graphs spike clearly without dipping to 0. +const STAGES = [ + { duration: '8s', target: 12 }, // SPIKE + { duration: '6s', target: 2 }, // crash + { duration: '8s', target: 16 }, // BIGGER SPIKE + { duration: '6s', target: 3 }, // crash + { duration: '8s', target: 10 }, // spike + { duration: '6s', target: 2 }, // crash + { duration: '8s', target: 18 }, // BIGGEST SPIKE + { duration: '8s', target: 0 }, // drain +]; + +const enabled = (__ENV.EMBER_TARGETS || 'fpm,franken,worker') + .split(',') + .map((s) => s.trim()) + .filter((s) => TARGETS[s]); + +const execFor = { fpm: 'hitFpm', franken: 'hitFranken', worker: 'hitWorker' }; + +const scenarios = {}; +for (const name of enabled) { + scenarios[name] = { + executor: 'ramping-vus', + exec: execFor[name], + startVUs: 0, + stages: STAGES, + tags: { target: name }, + }; +} + +export const options = { + insecureSkipTLSVerify: true, + scenarios, +}; + +// Load a pool of real product IDs once, up front, so every request hits a valid +// row. We always pull this list from FPM (8088) - it's the most resilient runtime, +// so warm-up never stalls even when we're stress-testing the worker. +export function setup() { + const base = TARGETS.fpm; + console.log(`Ember wave warming up - fetching product IDs from ${base}/en/products/db`); + + const res = http.get(`${base}/en/products/db`); + if (res.status !== 200) { + console.error(`Could not load product IDs (status ${res.status}). Did you run 'make setup'?`); + return { ids: [] }; + } + + const data = res.json(); + const ids = (data.products || []).map((p) => p.id); + console.log(`Loaded ${ids.length} product IDs. Targets: ${enabled.join(', ')}`); + return { ids }; +} + +function hit(base, data) { + if (!data.ids || data.ids.length === 0) { + return; + } + const id = data.ids[(__VU + __ITER) % data.ids.length]; + const res = http.get(`${base}/en/products/db/${id}`); + check(res, { 'status is 200': (r) => r.status === 200 }); + sleep(0.02); // tiny pause; each VU still hammers ~40+ req/s +} + +export function hitFpm(data) { hit(TARGETS.fpm, data); } +export function hitFranken(data) { hit(TARGETS.franken, data); } +export function hitWorker(data) { hit(TARGETS.worker, data); } diff --git a/migrations/Version20250704030635.php b/migrations/Version20250704030635.php index e97347d..b15856d 100644 --- a/migrations/Version20250704030635.php +++ b/migrations/Version20250704030635.php @@ -1,6 +1,13 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace DoctrineMigrations; diff --git a/migrations/Version20250704045634.php b/migrations/Version20250704045634.php index 10266d9..c367493 100644 --- a/migrations/Version20250704045634.php +++ b/migrations/Version20250704045634.php @@ -1,6 +1,13 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace DoctrineMigrations; diff --git a/migrations/Version20250704123501.php b/migrations/Version20250704123501.php index 2047e2b..60c8ec2 100644 --- a/migrations/Version20250704123501.php +++ b/migrations/Version20250704123501.php @@ -1,6 +1,13 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace DoctrineMigrations; diff --git a/migrations/Version20250704134414.php b/migrations/Version20250704134414.php index 16e455c..cb54626 100644 --- a/migrations/Version20250704134414.php +++ b/migrations/Version20250704134414.php @@ -1,6 +1,13 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace DoctrineMigrations; diff --git a/patches.lock.json b/patches.lock.json new file mode 100644 index 0000000..6aaf065 --- /dev/null +++ b/patches.lock.json @@ -0,0 +1,4 @@ +{ + "_hash": "6142bfcb78f54dfbf5247ae5e463f25bdb8fff1890806e2e45aa81a59c211653", + "patches": [] +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index aeff9d8..08dad3e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,360 @@ parameters: ignoreErrors: + - + message: "#^Method App\\\\Command\\\\AddOrderCommand\\:\\:__construct\\(\\) has parameter \\$items with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Command/AddOrderCommand.php + + - + message: "#^Method App\\\\Command\\\\AddOrderCommand\\:\\:getItems\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Command/AddOrderCommand.php + + - + message: "#^Cannot call method getId\\(\\) on mixed\\.$#" + count: 1 + path: src/Command/SeedDatabaseCommand.php + + - + message: "#^Cannot call method getName\\(\\) on mixed\\.$#" + count: 1 + path: src/Command/SeedDatabaseCommand.php + + - + message: "#^Cannot call method getPrice\\(\\) on mixed\\.$#" + count: 1 + path: src/Command/SeedDatabaseCommand.php + + - + message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\Product\\:\\:setName\\(\\) expects string, array\\|string given\\.$#" + count: 1 + path: src/Command/SeedDatabaseCommand.php + + - + message: "#^Parameter \\#1 \\$price of method App\\\\Entity\\\\Product\\:\\:setPrice\\(\\) expects string, float given\\.$#" + count: 1 + path: src/Command/SeedDatabaseCommand.php + + - + message: "#^Parameter \\#1 \\$status of method App\\\\Entity\\\\Order\\:\\:setStatus\\(\\) expects string, mixed given\\.$#" + count: 1 + path: src/Command/SeedDatabaseCommand.php + + - + message: "#^Parameter \\#1 \\$string of function mb_str_pad expects string, int\\<1, 10000\\> given\\.$#" + count: 1 + path: src/Command/SeedDatabaseCommand.php + + - + message: "#^Call to an undefined method object\\:\\:getName\\(\\)\\.$#" + count: 1 + path: src/CommandHandler/AddOrderCommandHandler.php + + - + message: "#^Call to an undefined method object\\:\\:getPrice\\(\\)\\.$#" + count: 1 + path: src/CommandHandler/AddOrderCommandHandler.php + + - + message: "#^Parameter \\#1 \\$price of method App\\\\Entity\\\\Product\\:\\:setPrice\\(\\) expects string, float given\\.$#" + count: 1 + path: src/CommandHandler/AddProductCommandHandler.php + + - + message: "#^Property App\\\\CommandHandler\\\\DeleteProductCommandHandler\\:\\:\\$summaryProjectionRepository is never read, only written\\.$#" + count: 1 + path: src/CommandHandler/DeleteProductCommandHandler.php + + - + message: "#^Method App\\\\CommandHandler\\\\GetCustomerCommandHandler\\:\\:__invoke\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/CommandHandler/GetCustomerCommandHandler.php + + - + message: "#^Method App\\\\CommandHandler\\\\GetOrderCommandHandler\\:\\:__invoke\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/CommandHandler/GetOrderCommandHandler.php + + - + message: "#^Method App\\\\CommandHandler\\\\GetProductCommandHandler\\:\\:__invoke\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/CommandHandler/GetProductCommandHandler.php + + - + message: "#^Method App\\\\CommandHandler\\\\ListCustomersCommandHandler\\:\\:__invoke\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/CommandHandler/ListCustomersCommandHandler.php + + - + message: "#^Method App\\\\CommandHandler\\\\ListOrdersCommandHandler\\:\\:__invoke\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/CommandHandler/ListOrdersCommandHandler.php + + - + message: "#^Method App\\\\CommandHandler\\\\ListProductsCommandHandler\\:\\:__invoke\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/CommandHandler/ListProductsCommandHandler.php + + - + message: "#^Call to an undefined method object\\:\\:setCreatedAt\\(\\)\\.$#" + count: 1 + path: src/CommandHandler/UpdateProductCommandHandler.php + + - + message: "#^Call to an undefined method object\\:\\:setDescription\\(\\)\\.$#" + count: 1 + path: src/CommandHandler/UpdateProductCommandHandler.php + + - + message: "#^Call to an undefined method object\\:\\:setName\\(\\)\\.$#" + count: 1 + path: src/CommandHandler/UpdateProductCommandHandler.php + + - + message: "#^Call to an undefined method object\\:\\:setPrice\\(\\)\\.$#" + count: 1 + path: src/CommandHandler/UpdateProductCommandHandler.php + + - + message: "#^Parameter \\#1 \\$product of method App\\\\Projection\\\\ProductProjectionService\\:\\:updateProjection\\(\\) expects App\\\\Entity\\\\Product, object given\\.$#" + count: 1 + path: src/CommandHandler/UpdateProductCommandHandler.php + + - + message: "#^Property App\\\\CommandHandler\\\\UpdateProductCommandHandler\\:\\:\\$summaryProjectionService is never read, only written\\.$#" + count: 1 + path: src/CommandHandler/UpdateProductCommandHandler.php + + - + message: "#^Cannot call method getResult\\(\\) on Symfony\\\\Component\\\\Messenger\\\\Stamp\\\\HandledStamp\\|null\\.$#" + count: 2 + path: src/Controller/CustomerController.php + + - + message: "#^Cannot call method toArray\\(\\) on mixed\\.$#" + count: 1 + path: src/Controller/CustomerController.php + + - + message: "#^Parameter \\#1 \\$datetime of class DateTimeImmutable constructor expects string, mixed given\\.$#" + count: 1 + path: src/Controller/CustomerController.php + + - + message: "#^Parameter \\#1 \\$name of class App\\\\Command\\\\AddCustomerCommand constructor expects string, mixed given\\.$#" + count: 1 + path: src/Controller/CustomerController.php + + - + message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#" + count: 1 + path: src/Controller/CustomerController.php + + - + message: "#^Parameter \\#2 \\$array of function array_map expects array, mixed given\\.$#" + count: 1 + path: src/Controller/CustomerController.php + + - + message: "#^Parameter \\#2 \\$email of class App\\\\Command\\\\AddCustomerCommand constructor expects string, mixed given\\.$#" + count: 1 + path: src/Controller/CustomerController.php + + - + message: "#^Parameter \\#3 \\$address of class App\\\\Command\\\\AddCustomerCommand constructor expects string, mixed given\\.$#" + count: 1 + path: src/Controller/CustomerController.php + + - + message: "#^Parameter \\#4 \\$city of class App\\\\Command\\\\AddCustomerCommand constructor expects string, mixed given\\.$#" + count: 1 + path: src/Controller/CustomerController.php + + - + message: "#^Parameter \\#5 \\$postalCode of class App\\\\Command\\\\AddCustomerCommand constructor expects string, mixed given\\.$#" + count: 1 + path: src/Controller/CustomerController.php + + - + message: "#^Parameter \\#6 \\$country of class App\\\\Command\\\\AddCustomerCommand constructor expects string, mixed given\\.$#" + count: 1 + path: src/Controller/CustomerController.php + + - + message: "#^Cannot access offset 'accepted conn' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'active processes' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'idle processes' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'listen queue len' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'listen queue' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'max active processes' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'max children reached' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'max listen queue' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'pool' on mixed\\.$#" + count: 2 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'process manager' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'processes' on mixed\\.$#" + count: 2 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'slow requests' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'start since' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'start time' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Cannot access offset 'total processes' on mixed\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Method App\\\\Controller\\\\Metrics\\\\FpmStatusController\\:\\:formatMetric\\(\\) has parameter \\$labels with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Method App\\\\Controller\\\\Metrics\\\\FpmStatusController\\:\\:formatMetric\\(\\) has parameter \\$value with no type specified\\.$#" + count: 1 + path: src/Controller/Metrics/FpmStatusController.php + + - + message: "#^Method App\\\\Controller\\\\Metrics\\\\OpcacheController\\:\\:formatMetric\\(\\) has parameter \\$value with no type specified\\.$#" + count: 1 + path: src/Controller/Metrics/OpcacheController.php + + - + message: "#^Cannot call method getResult\\(\\) on Symfony\\\\Component\\\\Messenger\\\\Stamp\\\\HandledStamp\\|null\\.$#" + count: 2 + path: src/Controller/OrderController.php + + - + message: "#^Cannot call method toArray\\(\\) on mixed\\.$#" + count: 1 + path: src/Controller/OrderController.php + + - + message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#" + count: 1 + path: src/Controller/OrderController.php + + - + message: "#^Parameter \\#2 \\$array of function array_map expects array, mixed given\\.$#" + count: 1 + path: src/Controller/OrderController.php + + - + message: "#^Call to an undefined method object\\:\\:getCreatedAt\\(\\)\\.$#" + count: 2 + path: src/Controller/ProductController.php + + - + message: "#^Call to an undefined method object\\:\\:getDescription\\(\\)\\.$#" + count: 2 + path: src/Controller/ProductController.php + + - + message: "#^Call to an undefined method object\\:\\:getId\\(\\)\\.$#" + count: 2 + path: src/Controller/ProductController.php + + - + message: "#^Call to an undefined method object\\:\\:getName\\(\\)\\.$#" + count: 2 + path: src/Controller/ProductController.php + + - + message: "#^Call to an undefined method object\\:\\:getPrice\\(\\)\\.$#" + count: 2 + path: src/Controller/ProductController.php + + - + message: "#^Cannot call method getResult\\(\\) on Symfony\\\\Component\\\\Messenger\\\\Stamp\\\\HandledStamp\\|null\\.$#" + count: 2 + path: src/Controller/ProductController.php + + - + message: "#^Cannot call method toArray\\(\\) on mixed\\.$#" + count: 1 + path: src/Controller/ProductController.php + + - + message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#" + count: 1 + path: src/Controller/ProductController.php + + - + message: "#^Parameter \\#2 \\$array of function array_map expects array, mixed given\\.$#" + count: 1 + path: src/Controller/ProductController.php + + - + message: "#^Method Doctrine\\\\Common\\\\DataFixtures\\\\AbstractFixture\\:\\:getReference\\(\\) invoked with 1 parameter, 2 required\\.$#" + count: 3 + path: src/DataFixtures/AppFixtures.php + + - + message: "#^Unable to resolve the template type T in call to method Doctrine\\\\Common\\\\DataFixtures\\\\AbstractFixture\\:\\:getReference\\(\\)$#" + count: 3 + path: src/DataFixtures/AppFixtures.php + + - + message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\Product\\:\\:setName\\(\\) expects string, array\\|string given\\.$#" + count: 1 + path: src/DataFixtures/ProductFixtures.php + + - + message: "#^Parameter \\#1 \\$price of method App\\\\Entity\\\\Product\\:\\:setPrice\\(\\) expects string, float given\\.$#" + count: 1 + path: src/DataFixtures/ProductFixtures.php + - message: "#^Property App\\\\Entity\\\\Comment\\:\\:\\$author type mapping mismatch\\: property can contain App\\\\Entity\\\\User\\|null but database expects App\\\\Entity\\\\User\\.$#" count: 1 @@ -15,6 +370,86 @@ parameters: count: 1 path: src/Entity/Comment.php + - + message: "#^Property App\\\\Entity\\\\Customer\\:\\:\\$address type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" + count: 1 + path: src/Entity/Customer.php + + - + message: "#^Property App\\\\Entity\\\\Customer\\:\\:\\$city type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" + count: 1 + path: src/Entity/Customer.php + + - + message: "#^Property App\\\\Entity\\\\Customer\\:\\:\\$country type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" + count: 1 + path: src/Entity/Customer.php + + - + message: "#^Property App\\\\Entity\\\\Customer\\:\\:\\$createdAt type mapping mismatch\\: property can contain DateTimeImmutable\\|null but database expects DateTimeImmutable\\.$#" + count: 1 + path: src/Entity/Customer.php + + - + message: "#^Property App\\\\Entity\\\\Customer\\:\\:\\$email type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" + count: 1 + path: src/Entity/Customer.php + + - + message: "#^Property App\\\\Entity\\\\Customer\\:\\:\\$name type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" + count: 1 + path: src/Entity/Customer.php + + - + message: "#^Property App\\\\Entity\\\\Customer\\:\\:\\$postalCode type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" + count: 1 + path: src/Entity/Customer.php + + - + message: "#^Method App\\\\Entity\\\\Order\\:\\:addItem\\(\\) has parameter \\$item with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Entity/Order.php + + - + message: "#^Method App\\\\Entity\\\\Order\\:\\:getItems\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Entity/Order.php + + - + message: "#^Method App\\\\Entity\\\\Order\\:\\:setItems\\(\\) has parameter \\$items with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Entity/Order.php + + - + message: "#^Property App\\\\Entity\\\\Order\\:\\:\\$createdAt type mapping mismatch\\: property can contain DateTimeImmutable\\|null but database expects DateTimeImmutable\\.$#" + count: 1 + path: src/Entity/Order.php + + - + message: "#^Property App\\\\Entity\\\\Order\\:\\:\\$customer type mapping mismatch\\: property can contain App\\\\Entity\\\\Customer\\|null but database expects App\\\\Entity\\\\Customer\\.$#" + count: 1 + path: src/Entity/Order.php + + - + message: "#^Property App\\\\Entity\\\\Order\\:\\:\\$items type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Entity/Order.php + + - + message: "#^Property App\\\\Entity\\\\Order\\:\\:\\$orderNumber type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" + count: 1 + path: src/Entity/Order.php + + - + message: "#^Property App\\\\Entity\\\\Order\\:\\:\\$status type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" + count: 1 + path: src/Entity/Order.php + + - + message: "#^Property App\\\\Entity\\\\Order\\:\\:\\$totalAmount type mapping mismatch\\: property can contain string\\|null but database expects float\\|int\\|string\\.$#" + count: 1 + path: src/Entity/Order.php + - message: "#^Property App\\\\Entity\\\\Post\\:\\:\\$author type mapping mismatch\\: property can contain App\\\\Entity\\\\User\\|null but database expects App\\\\Entity\\\\User\\.$#" count: 1 @@ -40,6 +475,31 @@ parameters: count: 1 path: src/Entity/Post.php + - + message: "#^Property App\\\\Entity\\\\Product\\:\\:\\$createdAt type mapping mismatch\\: property can contain DateTimeImmutable\\|null but database expects DateTimeImmutable\\.$#" + count: 1 + path: src/Entity/Product.php + + - + message: "#^Property App\\\\Entity\\\\Product\\:\\:\\$description type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" + count: 1 + path: src/Entity/Product.php + + - + message: "#^Property App\\\\Entity\\\\Product\\:\\:\\$name type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" + count: 1 + path: src/Entity/Product.php + + - + message: "#^Property App\\\\Entity\\\\Product\\:\\:\\$price type mapping mismatch\\: property can contain string\\|null but database expects float\\|int\\|string\\.$#" + count: 1 + path: src/Entity/Product.php + + - + message: "#^Method App\\\\Entity\\\\User\\:\\:getUserIdentifier\\(\\) should return non\\-empty\\-string but returns string\\.$#" + count: 1 + path: src/Entity/User.php + - message: "#^Property App\\\\Entity\\\\User\\:\\:\\$email type mapping mismatch\\: property can contain string\\|null but database expects string\\.$#" count: 1 @@ -60,6 +520,251 @@ parameters: count: 1 path: src/Entity/User.php + - + message: "#^Method App\\\\Projection\\\\CustomerProjection\\:\\:toArray\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/CustomerProjection.php + + - + message: "#^Parameter \\#1 \\$id of class App\\\\Projection\\\\CustomerProjection constructor expects int, int\\|null given\\.$#" + count: 1 + path: src/Projection/CustomerProjectionBuilder.php + + - + message: "#^Parameter \\#2 \\$name of class App\\\\Projection\\\\CustomerProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/CustomerProjectionBuilder.php + + - + message: "#^Parameter \\#3 \\$email of class App\\\\Projection\\\\CustomerProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/CustomerProjectionBuilder.php + + - + message: "#^Parameter \\#4 \\$address of class App\\\\Projection\\\\CustomerProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/CustomerProjectionBuilder.php + + - + message: "#^Parameter \\#5 \\$city of class App\\\\Projection\\\\CustomerProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/CustomerProjectionBuilder.php + + - + message: "#^Parameter \\#6 \\$postalCode of class App\\\\Projection\\\\CustomerProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/CustomerProjectionBuilder.php + + - + message: "#^Parameter \\#7 \\$country of class App\\\\Projection\\\\CustomerProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/CustomerProjectionBuilder.php + + - + message: "#^Parameter \\#8 \\$createdAt of class App\\\\Projection\\\\CustomerProjection constructor expects DateTimeImmutable, DateTimeImmutable\\|null given\\.$#" + count: 1 + path: src/Projection/CustomerProjectionBuilder.php + + - + message: "#^Method App\\\\Projection\\\\CustomerProjectionRepository\\:\\:buildFromArray\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/CustomerProjectionRepository.php + + - + message: "#^Method App\\\\Projection\\\\CustomerProjectionRepository\\:\\:findAll\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/CustomerProjectionRepository.php + + - + message: "#^Parameter \\#1 \\$data of method App\\\\Projection\\\\CustomerProjectionRepository\\:\\:buildFromArray\\(\\) expects array, mixed given\\.$#" + count: 2 + path: src/Projection/CustomerProjectionRepository.php + + - + message: "#^Parameter \\#2 \\$member of method Predis\\\\ClientInterface\\:\\:srem\\(\\) expects array\\|string, int given\\.$#" + count: 1 + path: src/Projection/CustomerProjectionRepository.php + + - + message: "#^Parameter \\#2 \\$members of method Predis\\\\ClientInterface\\:\\:sadd\\(\\) expects array, int given\\.$#" + count: 1 + path: src/Projection/CustomerProjectionRepository.php + + - + message: "#^Method App\\\\Projection\\\\OrderProjection\\:\\:__construct\\(\\) has parameter \\$items with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/OrderProjection.php + + - + message: "#^Method App\\\\Projection\\\\OrderProjection\\:\\:getItems\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/OrderProjection.php + + - + message: "#^Method App\\\\Projection\\\\OrderProjection\\:\\:toArray\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/OrderProjection.php + + - + message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" + count: 1 + path: src/Projection/OrderProjectionBuilder.php + + - + message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" + count: 1 + path: src/Projection/OrderProjectionBuilder.php + + - + message: "#^Parameter \\#1 \\$id of class App\\\\Projection\\\\OrderProjection constructor expects int, int\\|null given\\.$#" + count: 1 + path: src/Projection/OrderProjectionBuilder.php + + - + message: "#^Parameter \\#2 \\$customerId of class App\\\\Projection\\\\OrderProjection constructor expects int, int\\|null given\\.$#" + count: 1 + path: src/Projection/OrderProjectionBuilder.php + + - + message: "#^Parameter \\#3 \\$customerName of class App\\\\Projection\\\\OrderProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/OrderProjectionBuilder.php + + - + message: "#^Parameter \\#4 \\$orderNumber of class App\\\\Projection\\\\OrderProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/OrderProjectionBuilder.php + + - + message: "#^Parameter \\#5 \\$totalAmount of class App\\\\Projection\\\\OrderProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/OrderProjectionBuilder.php + + - + message: "#^Parameter \\#6 \\$status of class App\\\\Projection\\\\OrderProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/OrderProjectionBuilder.php + + - + message: "#^Parameter \\#8 \\$createdAt of class App\\\\Projection\\\\OrderProjection constructor expects DateTimeImmutable, DateTimeImmutable\\|null given\\.$#" + count: 1 + path: src/Projection/OrderProjectionBuilder.php + + - + message: "#^Method App\\\\Projection\\\\OrderProjectionRepository\\:\\:buildFromArray\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/OrderProjectionRepository.php + + - + message: "#^Method App\\\\Projection\\\\OrderProjectionRepository\\:\\:findAll\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/OrderProjectionRepository.php + + - + message: "#^Method App\\\\Projection\\\\OrderProjectionRepository\\:\\:findByCustomer\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/OrderProjectionRepository.php + + - + message: "#^Parameter \\#1 \\$data of method App\\\\Projection\\\\OrderProjectionRepository\\:\\:buildFromArray\\(\\) expects array, mixed given\\.$#" + count: 3 + path: src/Projection/OrderProjectionRepository.php + + - + message: "#^Parameter \\#2 \\$member of method Predis\\\\ClientInterface\\:\\:srem\\(\\) expects array\\|string, int given\\.$#" + count: 2 + path: src/Projection/OrderProjectionRepository.php + + - + message: "#^Parameter \\#2 \\$members of method Predis\\\\ClientInterface\\:\\:sadd\\(\\) expects array, int given\\.$#" + count: 2 + path: src/Projection/OrderProjectionRepository.php + + - + message: "#^Method App\\\\Projection\\\\ProductProjection\\:\\:toArray\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/ProductProjection.php + + - + message: "#^Parameter \\#1 \\$id of class App\\\\Projection\\\\ProductProjection constructor expects int, int\\|null given\\.$#" + count: 1 + path: src/Projection/ProductProjectionBuilder.php + + - + message: "#^Parameter \\#2 \\$name of class App\\\\Projection\\\\ProductProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/ProductProjectionBuilder.php + + - + message: "#^Parameter \\#3 \\$description of class App\\\\Projection\\\\ProductProjection constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/Projection/ProductProjectionBuilder.php + + - + message: "#^Parameter \\#4 \\$price of class App\\\\Projection\\\\ProductProjection constructor expects float, string\\|null given\\.$#" + count: 1 + path: src/Projection/ProductProjectionBuilder.php + + - + message: "#^Parameter \\#5 \\$createdAt of class App\\\\Projection\\\\ProductProjection constructor expects DateTimeImmutable, DateTimeImmutable\\|null given\\.$#" + count: 1 + path: src/Projection/ProductProjectionBuilder.php + + - + message: "#^Method App\\\\Projection\\\\ProductProjectionRepository\\:\\:find\\(\\) should return App\\\\Projection\\\\ProductProjection\\|null but returns mixed\\.$#" + count: 1 + path: src/Projection/ProductProjectionRepository.php + + - + message: "#^Method App\\\\Projection\\\\ProductProjectionRepository\\:\\:findAll\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/ProductProjectionRepository.php + + - + message: "#^Parameter \\#2 \\$member of method Predis\\\\ClientInterface\\:\\:srem\\(\\) expects array\\|string, int given\\.$#" + count: 1 + path: src/Projection/ProductProjectionRepository.php + + - + message: "#^Parameter \\#1 \\$product of method App\\\\Projection\\\\ProductProjectionBuilder\\:\\:build\\(\\) expects App\\\\Entity\\\\Product, object given\\.$#" + count: 1 + path: src/Projection/ProductProjectionService.php + + - + message: "#^Method App\\\\Projection\\\\ProductSummaryProjection\\:\\:fromArray\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/ProductSummaryProjection.php + + - + message: "#^Method App\\\\Projection\\\\ProductSummaryProjection\\:\\:toArray\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/ProductSummaryProjection.php + + - + message: "#^Method App\\\\Projection\\\\ProductSummaryProjectionBuilder\\:\\:getAllProductIds\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/ProductSummaryProjectionBuilder.php + + - + message: "#^Method App\\\\Projection\\\\ProductSummaryProjectionBuilder\\:\\:getProductSummaryData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/ProductSummaryProjectionBuilder.php + + - + message: "#^Method App\\\\Projection\\\\ProductSummaryProjectionRepository\\:\\:findAll\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Projection/ProductSummaryProjectionRepository.php + + - + message: "#^Parameter \\#2 \\$member of method Predis\\\\ClientInterface\\:\\:srem\\(\\) expects array\\|string, int given\\.$#" + count: 1 + path: src/Projection/ProductSummaryProjectionRepository.php + + - + message: "#^Class App\\\\Repository\\\\ProductRepository extends generic class Doctrine\\\\Bundle\\\\DoctrineBundle\\\\Repository\\\\ServiceEntityRepository but does not specify its types\\: TEntityClass$#" + count: 1 + path: src/Repository/ProductRepository.php + - message: "#^Parameter \\#1 \\$function of class ReflectionFunction constructor expects Closure\\|string, callable\\(\\)\\: mixed given\\.$#" count: 1 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 1eeeb90..8b5ce74 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,44 +1,41 @@ - - - - - - - - - - - - - - tests - - - - - - src - - - - - - - - - + tests/Command/AddUserCommandTest.php + tests/Command/ListUsersCommandTest.php + tests/Controller/BlogControllerTest.php + tests/Controller/Admin/BlogControllerTest.php + tests/Controller/DefaultControllerTest.php + tests/Controller/UserControllerTest.php + + + + - - - - - + + + + + + + src + + diff --git a/public/agent-pull.php b/public/agent-pull.php index 81f9f12..39f20ca 100644 --- a/public/agent-pull.php +++ b/public/agent-pull.php @@ -1,18 +1,15 @@ * - * MIT License + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. */ -declare(strict_types=1); -/** +/* * Check opcache extension configured */ if (!function_exists('opcache_get_status')) { @@ -20,18 +17,19 @@ 500, ['error' => 'Opcache extension not loaded'] ); + return; } /** - * Router + * Router. */ -$command = (string) filter_input(INPUT_GET, 'command'); +$command = (string) filter_input(\INPUT_GET, 'command'); switch ($command) { case '': case 'status': - $pretty = (bool) filter_input(INPUT_GET, 'pretty'); - $scripts = (bool) filter_input(INPUT_GET, 'scripts'); + $pretty = (bool) filter_input(\INPUT_GET, 'pretty'); + $scripts = (bool) filter_input(\INPUT_GET, 'scripts'); statusCommand($pretty, $scripts); break; @@ -40,7 +38,7 @@ break; case 'invalidate': - $scriptPath = (string) filter_input(INPUT_GET, 'script'); + $scriptPath = (string) filter_input(\INPUT_GET, 'script'); invalidateCommand($scriptPath); break; @@ -50,7 +48,7 @@ } /** - * Complete reset of opcache + * Complete reset of opcache. */ function resetCommand(): void { @@ -60,17 +58,19 @@ function resetCommand(): void } /** - * Invalidate command invalidates passed script in OPcache + * Invalidate command invalidates passed script in OPcache. */ function invalidateCommand(string $scriptPath): void { if (empty($scriptPath)) { sendResponse(400, ['error' => 'Script not defined']); + return; } if (!file_exists($scriptPath)) { sendResponse(404, ['error' => 'Script not found']); + return; } @@ -80,7 +80,7 @@ function invalidateCommand(string $scriptPath): void } /** - * Status command return status of OPcache + * Status command return status of OPcache. */ function statusCommand(bool $pretty, bool $scripts): void { @@ -103,7 +103,7 @@ function sendResponse(int $code, array $body, bool $pretty = false): void $jsonEncodeFlags = 0; if ($pretty) { - $jsonEncodeFlags |= JSON_PRETTY_PRINT; + $jsonEncodeFlags |= \JSON_PRETTY_PRINT; } echo json_encode( @@ -125,4 +125,4 @@ function getApcuStatus(): array 'smaInfo' => apcu_sma_info(), 'settings' => ini_get_all('apcu'), ]; -} \ No newline at end of file +} diff --git a/src/Command/AddCustomerCommand.php b/src/Command/AddCustomerCommand.php index c745b06..b3a198f 100644 --- a/src/Command/AddCustomerCommand.php +++ b/src/Command/AddCustomerCommand.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class AddCustomerCommand @@ -11,8 +20,9 @@ public function __construct( public string $city, public string $postalCode, public string $country, - public \DateTimeImmutable $createdAt - ) {} + public \DateTimeImmutable $createdAt, + ) { + } public function getName(): string { @@ -48,4 +58,4 @@ public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } -} \ No newline at end of file +} diff --git a/src/Command/AddOrderCommand.php b/src/Command/AddOrderCommand.php index de6d59c..9353139 100644 --- a/src/Command/AddOrderCommand.php +++ b/src/Command/AddOrderCommand.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class AddOrderCommand @@ -10,8 +19,9 @@ public function __construct( public string $totalAmount, public string $status, public array $items, - public \DateTimeImmutable $createdAt - ) {} + public \DateTimeImmutable $createdAt, + ) { + } public function getCustomerId(): int { @@ -42,4 +52,4 @@ public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } -} \ No newline at end of file +} diff --git a/src/Command/AddProductCommand.php b/src/Command/AddProductCommand.php index 0dcad51..a3eb3b6 100644 --- a/src/Command/AddProductCommand.php +++ b/src/Command/AddProductCommand.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class AddProductCommand @@ -8,8 +17,9 @@ public function __construct( public string $name, public string $description, public float $price, - public \DateTimeImmutable $createdAt - ) {} + public \DateTimeImmutable $createdAt, + ) { + } public function getName(): string { @@ -30,4 +40,4 @@ public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } -} \ No newline at end of file +} diff --git a/src/Command/AddUserCommand.php b/src/Command/AddUserCommand.php index b6a2f7e..a060f81 100644 --- a/src/Command/AddUserCommand.php +++ b/src/Command/AddUserCommand.php @@ -59,7 +59,7 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly UserPasswordHasherInterface $passwordHasher, private readonly Validator $validator, - private readonly UserRepository $users + private readonly UserRepository $users, ) { parent::__construct(); } @@ -198,12 +198,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->entityManager->persist($user); $this->entityManager->flush(); - $this->io->success(sprintf('%s was successfully created: %s (%s)', $isAdmin ? 'Administrator user' : 'User', $user->getUsername(), $user->getEmail())); + $this->io->success(\sprintf('%s was successfully created: %s (%s)', $isAdmin ? 'Administrator user' : 'User', $user->getUsername(), $user->getEmail())); $event = $stopwatch->stop('add-user-command'); if ($output->isVerbose()) { - $this->io->comment(sprintf('New user database id: %d / Elapsed time: %.2f ms / Consumed memory: %.2f MB', $user->getId(), $event->getDuration(), $event->getMemory() / (1024 ** 2))); + $this->io->comment(\sprintf('New user database id: %d / Elapsed time: %.2f ms / Consumed memory: %.2f MB', $user->getId(), $event->getDuration(), $event->getMemory() / (1024 ** 2))); } return Command::SUCCESS; @@ -215,7 +215,7 @@ private function validateUserData(string $username, string $plainPassword, strin $existingUser = $this->users->findOneBy(['username' => $username]); if (null !== $existingUser) { - throw new RuntimeException(sprintf('There is already a user registered with the "%s" username.', $username)); + throw new RuntimeException(\sprintf('There is already a user registered with the "%s" username.', $username)); } // validate password and email if is not this input means interactive. @@ -227,7 +227,7 @@ private function validateUserData(string $username, string $plainPassword, strin $existingEmail = $this->users->findOneBy(['email' => $email]); if (null !== $existingEmail) { - throw new RuntimeException(sprintf('There is already a user registered with the "%s" email.', $email)); + throw new RuntimeException(\sprintf('There is already a user registered with the "%s" email.', $email)); } } diff --git a/src/Command/DeleteCustomerCommand.php b/src/Command/DeleteCustomerCommand.php index 78d131c..71e3fff 100644 --- a/src/Command/DeleteCustomerCommand.php +++ b/src/Command/DeleteCustomerCommand.php @@ -1,15 +1,25 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class DeleteCustomerCommand { public function __construct( - public int $id - ) {} + public int $id, + ) { + } public function getId(): int { return $this->id; } -} \ No newline at end of file +} diff --git a/src/Command/DeleteOrderCommand.php b/src/Command/DeleteOrderCommand.php index 943c1ef..6acaf24 100644 --- a/src/Command/DeleteOrderCommand.php +++ b/src/Command/DeleteOrderCommand.php @@ -1,15 +1,25 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class DeleteOrderCommand { public function __construct( - public int $id - ) {} + public int $id, + ) { + } public function getId(): int { return $this->id; } -} \ No newline at end of file +} diff --git a/src/Command/DeleteProductCommand.php b/src/Command/DeleteProductCommand.php index 4a2a029..2fa8940 100644 --- a/src/Command/DeleteProductCommand.php +++ b/src/Command/DeleteProductCommand.php @@ -1,10 +1,20 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class DeleteProductCommand { public function __construct( - public int $productId - ) {} -} \ No newline at end of file + public int $productId, + ) { + } +} diff --git a/src/Command/DeleteUserCommand.php b/src/Command/DeleteUserCommand.php index 2f11a36..2c91523 100644 --- a/src/Command/DeleteUserCommand.php +++ b/src/Command/DeleteUserCommand.php @@ -51,7 +51,7 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly Validator $validator, private readonly UserRepository $users, - private readonly LoggerInterface $logger + private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -115,7 +115,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $user = $this->users->findOneByUsername($username); if (null === $user) { - throw new RuntimeException(sprintf('User with username "%s" not found.', $username)); + throw new RuntimeException(\sprintf('User with username "%s" not found.', $username)); } // After an entity has been removed, its in-memory state is the same @@ -129,7 +129,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $userUsername = $user->getUsername(); $userEmail = $user->getEmail(); - $this->io->success(sprintf('User "%s" (ID: %d, email: %s) was successfully deleted.', $userUsername, $userId, $userEmail)); + $this->io->success(\sprintf('User "%s" (ID: %d, email: %s) was successfully deleted.', $userUsername, $userId, $userEmail)); // Logging is helpful and important to keep a trace of what happened in the software runtime flow. // See https://symfony.com/doc/current/logging.html diff --git a/src/Command/GetCustomerCommand.php b/src/Command/GetCustomerCommand.php index 62f7e52..1bc12f4 100644 --- a/src/Command/GetCustomerCommand.php +++ b/src/Command/GetCustomerCommand.php @@ -1,15 +1,25 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class GetCustomerCommand { public function __construct( - public int $id - ) {} + public int $id, + ) { + } public function getId(): int { return $this->id; } -} \ No newline at end of file +} diff --git a/src/Command/GetOrderCommand.php b/src/Command/GetOrderCommand.php index b622a30..f6822c9 100644 --- a/src/Command/GetOrderCommand.php +++ b/src/Command/GetOrderCommand.php @@ -1,15 +1,25 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class GetOrderCommand { public function __construct( - public int $id - ) {} + public int $id, + ) { + } public function getId(): int { return $this->id; } -} \ No newline at end of file +} diff --git a/src/Command/GetProductCommand.php b/src/Command/GetProductCommand.php index 5a3aacc..d02d956 100644 --- a/src/Command/GetProductCommand.php +++ b/src/Command/GetProductCommand.php @@ -1,10 +1,20 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class GetProductCommand { public function __construct( - public int $productId - ) {} -} \ No newline at end of file + public int $productId, + ) { + } +} diff --git a/src/Command/ListCustomersCommand.php b/src/Command/ListCustomersCommand.php index cca4707..fa663f9 100644 --- a/src/Command/ListCustomersCommand.php +++ b/src/Command/ListCustomersCommand.php @@ -1,8 +1,17 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class ListCustomersCommand { // Empty command for listing all customers -} \ No newline at end of file +} diff --git a/src/Command/ListOrdersCommand.php b/src/Command/ListOrdersCommand.php index 72502a6..1f91262 100644 --- a/src/Command/ListOrdersCommand.php +++ b/src/Command/ListOrdersCommand.php @@ -1,8 +1,17 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class ListOrdersCommand { // Empty command for listing all orders -} \ No newline at end of file +} diff --git a/src/Command/ListProductsCommand.php b/src/Command/ListProductsCommand.php index d8cb07c..d8839cb 100644 --- a/src/Command/ListProductsCommand.php +++ b/src/Command/ListProductsCommand.php @@ -1,8 +1,17 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class ListProductsCommand { // Empty command for listing all products -} \ No newline at end of file +} diff --git a/src/Command/ListUsersCommand.php b/src/Command/ListUsersCommand.php index 0ac7645..41e8c25 100644 --- a/src/Command/ListUsersCommand.php +++ b/src/Command/ListUsersCommand.php @@ -50,7 +50,7 @@ public function __construct( private readonly MailerInterface $mailer, #[Autowire('%app.notifications.email_sender%')] private readonly string $emailSender, - private readonly UserRepository $users + private readonly UserRepository $users, ) { parent::__construct(); } @@ -140,7 +140,7 @@ private function sendReport(string $contents, string $recipient): void $email = (new Email()) ->from($this->emailSender) ->to($recipient) - ->subject(sprintf('app:list-users report (%s)', date('Y-m-d H:i:s'))) + ->subject(\sprintf('app:list-users report (%s)', date('Y-m-d H:i:s'))) ->text($contents); $this->mailer->send($email); diff --git a/src/Command/RebuildCustomerProjectionsCommand.php b/src/Command/RebuildCustomerProjectionsCommand.php index 3bba25b..6b51d35 100644 --- a/src/Command/RebuildCustomerProjectionsCommand.php +++ b/src/Command/RebuildCustomerProjectionsCommand.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; use App\Projection\CustomerProjectionService; @@ -16,7 +25,7 @@ final class RebuildCustomerProjectionsCommand extends Command { public function __construct( - private readonly CustomerProjectionService $projectionService + private readonly CustomerProjectionService $projectionService, ) { parent::__construct(); } @@ -34,4 +43,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } -} \ No newline at end of file +} diff --git a/src/Command/RebuildOrderProjectionsCommand.php b/src/Command/RebuildOrderProjectionsCommand.php index bc76fd0..531c8c7 100644 --- a/src/Command/RebuildOrderProjectionsCommand.php +++ b/src/Command/RebuildOrderProjectionsCommand.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; use App\Projection\OrderProjectionService; @@ -16,7 +25,7 @@ final class RebuildOrderProjectionsCommand extends Command { public function __construct( - private readonly OrderProjectionService $projectionService + private readonly OrderProjectionService $projectionService, ) { parent::__construct(); } @@ -34,4 +43,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } -} \ No newline at end of file +} diff --git a/src/Command/RebuildProductProjectionsCommand.php b/src/Command/RebuildProductProjectionsCommand.php index 51fe208..1f13eda 100644 --- a/src/Command/RebuildProductProjectionsCommand.php +++ b/src/Command/RebuildProductProjectionsCommand.php @@ -1,17 +1,21 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; -use App\Entity\Product; -use App\Projection\ProductDbProjectionBuilder; -use App\Repository\ProductRepository; -use Doctrine\ORM\EntityManagerInterface; +use App\Projection\ProductProjectionService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use App\Projection\ProductProjectionService; -use App\Projection\ProductDbProjectionService; #[AsCommand( name: 'app:rebuild-product-projections', @@ -20,7 +24,7 @@ class RebuildProductProjectionsCommand extends Command { public function __construct( - private readonly ProductProjectionService $redisProjectionService + private readonly ProductProjectionService $redisProjectionService, ) { parent::__construct(); } @@ -30,6 +34,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln('Rebuilding Redis product projections...'); $this->redisProjectionService->rebuildAll(); $output->writeln('Done!'); + return Command::SUCCESS; } -} \ No newline at end of file +} diff --git a/src/Command/SeedDatabaseCommand.php b/src/Command/SeedDatabaseCommand.php index 4beda68..e0d3d3d 100644 --- a/src/Command/SeedDatabaseCommand.php +++ b/src/Command/SeedDatabaseCommand.php @@ -1,15 +1,21 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; use App\Entity\Customer; use App\Entity\Order; use App\Entity\Product; -use App\Repository\CustomerRepository; -use App\Repository\OrderRepository; use App\Projection\CustomerProjectionService; use App\Projection\OrderProjectionService; -use App\Repository\ProductRepository; use Doctrine\ORM\EntityManagerInterface; use Faker\Factory; use Symfony\Component\Console\Attribute\AsCommand; @@ -27,7 +33,7 @@ final class SeedDatabaseCommand extends Command public function __construct( private readonly EntityManagerInterface $entityManager, private readonly CustomerProjectionService $customerProjectionService, - private readonly OrderProjectionService $orderProjectionService + private readonly OrderProjectionService $orderProjectionService, ) { parent::__construct(); } @@ -49,7 +55,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Create Products $io->section('Creating 10,000 products...'); $products = []; - for ($i = 0; $i < 10000; $i++) { + for ($i = 0; $i < 10000; ++$i) { $product = new Product(); $product->setName($faker->words(3, true)); $product->setDescription($faker->paragraph()); @@ -61,7 +67,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (($i + 1) % 100 === 0) { $this->entityManager->flush(); - $io->text("Created " . ($i + 1) . " products"); + $io->text('Created '.($i + 1).' products'); } } $this->entityManager->flush(); @@ -70,7 +76,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Create Customers $io->section('Creating 5,000 customers...'); $customers = []; - for ($i = 0; $i < 5000; $i++) { + for ($i = 0; $i < 5000; ++$i) { $customer = new Customer(); $customer->setName($faker->name()); $customer->setEmail($faker->unique()->safeEmail()); @@ -85,7 +91,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (($i + 1) % 1000 === 0) { $this->entityManager->flush(); - $io->text("Created " . ($i + 1) . " customers"); + $io->text('Created '.($i + 1).' customers'); } } $this->entityManager->flush(); @@ -94,15 +100,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Create Orders (2 per customer) $io->section('Creating 20,000 orders (2 per customer)...'); $orderStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled']; - - for ($i = 0; $i < 5000; $i++) { + + for ($i = 0; $i < 5000; ++$i) { $customer = $customers[$i]; - + // Create 2 orders per customer - for ($j = 0; $j < 2; $j++) { + for ($j = 0; $j < 2; ++$j) { $order = new Order(); $order->setCustomer($customer); - $order->setOrderNumber('ORD-' . str_pad($i * 2 + $j + 1, 6, '0', STR_PAD_LEFT)); + $order->setOrderNumber('ORD-'.mb_str_pad($i * 2 + $j + 1, 6, '0', \STR_PAD_LEFT)); $order->setStatus($faker->randomElement($orderStatuses)); $order->setCreatedAt(\DateTimeImmutable::createFromMutable($faker->dateTimeBetween('-6 months', 'now'))); @@ -110,23 +116,23 @@ protected function execute(InputInterface $input, OutputInterface $output): int $orderItems = []; $totalAmount = 0; $numItems = $faker->numberBetween(1, 5); - - for ($k = 0; $k < $numItems; $k++) { + + for ($k = 0; $k < $numItems; ++$k) { $product = $faker->randomElement($products); $quantity = $faker->numberBetween(1, 5); $price = $product->getPrice(); $itemTotal = $price * $quantity; $totalAmount += $itemTotal; - + $orderItems[] = [ 'product_id' => $product->getId(), 'product_name' => $product->getName(), 'quantity' => $quantity, 'price' => $price, - 'total' => $itemTotal + 'total' => $itemTotal, ]; } - + $order->setItems($orderItems); $order->setTotalAmount(number_format($totalAmount, 2, '.', '')); @@ -135,7 +141,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (($i + 1) % 1000 === 0) { $this->entityManager->flush(); - $io->text("Created " . (($i + 1) * 2) . " orders"); + $io->text('Created '.(($i + 1) * 2).' orders'); } } $this->entityManager->flush(); @@ -156,4 +162,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } -} \ No newline at end of file +} diff --git a/src/Command/UpdateProductCommand.php b/src/Command/UpdateProductCommand.php index 9044711..815bf83 100644 --- a/src/Command/UpdateProductCommand.php +++ b/src/Command/UpdateProductCommand.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Command; readonly class UpdateProductCommand @@ -9,6 +18,7 @@ public function __construct( public ?string $name = null, public ?string $description = null, public ?float $price = null, - public ?\DateTimeImmutable $createdAt = null - ) {} -} \ No newline at end of file + public ?\DateTimeImmutable $createdAt = null, + ) { + } +} diff --git a/src/CommandHandler/AddCustomerCommandHandler.php b/src/CommandHandler/AddCustomerCommandHandler.php index 57ba8c7..c1674e7 100644 --- a/src/CommandHandler/AddCustomerCommandHandler.php +++ b/src/CommandHandler/AddCustomerCommandHandler.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\AddCustomerCommand; @@ -13,8 +22,9 @@ final class AddCustomerCommandHandler { public function __construct( private readonly CustomerRepository $customerRepository, - private readonly CustomerProjectionService $projectionService - ) {} + private readonly CustomerProjectionService $projectionService, + ) { + } public function __invoke(AddCustomerCommand $command): void { @@ -30,4 +40,4 @@ public function __invoke(AddCustomerCommand $command): void $this->customerRepository->save($customer, true); $this->projectionService->updateProjection($customer); } -} \ No newline at end of file +} diff --git a/src/CommandHandler/AddOrderCommandHandler.php b/src/CommandHandler/AddOrderCommandHandler.php index b878a92..d563312 100644 --- a/src/CommandHandler/AddOrderCommandHandler.php +++ b/src/CommandHandler/AddOrderCommandHandler.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\AddOrderCommand; @@ -17,13 +26,14 @@ public function __construct( private readonly OrderRepository $orderRepository, private readonly CustomerRepository $customerRepository, private readonly OrderProjectionService $projectionService, - private readonly ProductRepository $productRepository - ) {} + private readonly ProductRepository $productRepository, + ) { + } public function __invoke(AddOrderCommand $command): void { $customer = $this->customerRepository->find($command->customerId); - + if (!$customer) { throw new \InvalidArgumentException('Customer not found'); } @@ -33,17 +43,21 @@ public function __invoke(AddOrderCommand $command): void foreach ($command->items as $item) { $productId = $item['product_id'] ?? null; $quantity = $item['quantity'] ?? 1; - if (!$productId) continue; + if (!$productId) { + continue; + } $product = $this->productRepository->find($productId); - if (!$product) continue; - $price = (float)$product->getPrice(); + if (!$product) { + continue; + } + $price = (float) $product->getPrice(); $total = $price * $quantity; $orderItems[] = [ 'product_id' => $productId, 'product_name' => $product->getName(), 'quantity' => $quantity, 'price' => $price, - 'total' => $total + 'total' => $total, ]; $totalAmount += $total; } @@ -59,4 +73,4 @@ public function __invoke(AddOrderCommand $command): void $this->orderRepository->save($order, true); $this->projectionService->updateProjection($order); } -} \ No newline at end of file +} diff --git a/src/CommandHandler/AddProductCommandHandler.php b/src/CommandHandler/AddProductCommandHandler.php index 5dc8019..bf44050 100644 --- a/src/CommandHandler/AddProductCommandHandler.php +++ b/src/CommandHandler/AddProductCommandHandler.php @@ -1,24 +1,32 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\AddProductCommand; use App\Entity\Product; -use App\Repository\ProductRepository; -use App\Service\ProductSummaryProjectionService; use App\Projection\ProductProjectionService; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Psr\Log\LoggerInterface; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final readonly class AddProductCommandHandler { public function __construct( - private ProductProjectionService $productProjectionService, - private EntityManagerInterface $entityManager, - private LoggerInterface $logger - ) {} + private ProductProjectionService $productProjectionService, + private EntityManagerInterface $entityManager, + private LoggerInterface $logger, + ) { + } public function __invoke(AddProductCommand $command): void { @@ -46,4 +54,4 @@ public function __invoke(AddProductCommand $command): void throw $e; } } -} \ No newline at end of file +} diff --git a/src/CommandHandler/DeleteCustomerCommandHandler.php b/src/CommandHandler/DeleteCustomerCommandHandler.php index 93b489b..ef34dcd 100644 --- a/src/CommandHandler/DeleteCustomerCommandHandler.php +++ b/src/CommandHandler/DeleteCustomerCommandHandler.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\DeleteCustomerCommand; @@ -12,16 +21,17 @@ final class DeleteCustomerCommandHandler { public function __construct( private readonly CustomerRepository $customerRepository, - private readonly CustomerProjectionService $projectionService - ) {} + private readonly CustomerProjectionService $projectionService, + ) { + } public function __invoke(DeleteCustomerCommand $command): void { $customer = $this->customerRepository->find($command->id); - + if ($customer) { $this->customerRepository->remove($customer, true); $this->projectionService->deleteProjection($command->id); } } -} \ No newline at end of file +} diff --git a/src/CommandHandler/DeleteOrderCommandHandler.php b/src/CommandHandler/DeleteOrderCommandHandler.php index 05933bf..c3e2c14 100644 --- a/src/CommandHandler/DeleteOrderCommandHandler.php +++ b/src/CommandHandler/DeleteOrderCommandHandler.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\DeleteOrderCommand; @@ -12,16 +21,17 @@ final class DeleteOrderCommandHandler { public function __construct( private readonly OrderRepository $orderRepository, - private readonly OrderProjectionService $projectionService - ) {} + private readonly OrderProjectionService $projectionService, + ) { + } public function __invoke(DeleteOrderCommand $command): void { $order = $this->orderRepository->find($command->id); - + if ($order) { $this->orderRepository->remove($order, true); $this->projectionService->deleteProjection($command->id); } } -} \ No newline at end of file +} diff --git a/src/CommandHandler/DeleteProductCommandHandler.php b/src/CommandHandler/DeleteProductCommandHandler.php index 7f9023d..08f0326 100644 --- a/src/CommandHandler/DeleteProductCommandHandler.php +++ b/src/CommandHandler/DeleteProductCommandHandler.php @@ -1,13 +1,22 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\DeleteProductCommand; use App\Projection\ProductProjectionRepository; use App\Projection\ProductSummaryProjectionRepository; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Psr\Log\LoggerInterface; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final readonly class DeleteProductCommandHandler @@ -16,8 +25,9 @@ public function __construct( private ProductProjectionRepository $projectionRepository, private ProductSummaryProjectionRepository $summaryProjectionRepository, private EntityManagerInterface $entityManager, - private LoggerInterface $logger - ) {} + private LoggerInterface $logger, + ) { + } public function __invoke(DeleteProductCommand $command): void { @@ -32,7 +42,7 @@ public function __invoke(DeleteProductCommand $command): void } $this->projectionRepository->delete($command->productId); -// $this->summaryProjectionRepository->remove($command->productId); + // $this->summaryProjectionRepository->remove($command->productId); $this->entityManager->commit(); } catch (\Throwable $e) { @@ -44,4 +54,4 @@ public function __invoke(DeleteProductCommand $command): void throw $e; } } -} \ No newline at end of file +} diff --git a/src/CommandHandler/GetCustomerCommandHandler.php b/src/CommandHandler/GetCustomerCommandHandler.php index 02c5f16..8d23e62 100644 --- a/src/CommandHandler/GetCustomerCommandHandler.php +++ b/src/CommandHandler/GetCustomerCommandHandler.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\GetCustomerCommand; @@ -10,17 +19,18 @@ final class GetCustomerCommandHandler { public function __construct( - private readonly CustomerProjectionRepository $projectionRepository - ) {} + private readonly CustomerProjectionRepository $projectionRepository, + ) { + } public function __invoke(GetCustomerCommand $command): ?array { $projection = $this->projectionRepository->find($command->id); - + if (!$projection) { return null; } return $projection->toArray(); } -} \ No newline at end of file +} diff --git a/src/CommandHandler/GetOrderCommandHandler.php b/src/CommandHandler/GetOrderCommandHandler.php index c41972d..ad0c29d 100644 --- a/src/CommandHandler/GetOrderCommandHandler.php +++ b/src/CommandHandler/GetOrderCommandHandler.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\GetOrderCommand; @@ -10,17 +19,18 @@ final class GetOrderCommandHandler { public function __construct( - private readonly OrderProjectionRepository $projectionRepository - ) {} + private readonly OrderProjectionRepository $projectionRepository, + ) { + } public function __invoke(GetOrderCommand $command): ?array { $projection = $this->projectionRepository->find($command->id); - + if (!$projection) { return null; } return $projection->toArray(); } -} \ No newline at end of file +} diff --git a/src/CommandHandler/GetProductCommandHandler.php b/src/CommandHandler/GetProductCommandHandler.php index e05c96d..953b76a 100644 --- a/src/CommandHandler/GetProductCommandHandler.php +++ b/src/CommandHandler/GetProductCommandHandler.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\GetProductCommand; @@ -10,17 +19,18 @@ final class GetProductCommandHandler { public function __construct( - private readonly ProductProjectionRepository $projectionRepository - ) {} + private readonly ProductProjectionRepository $projectionRepository, + ) { + } public function __invoke(GetProductCommand $command): ?array { $projection = $this->projectionRepository->find($command->productId); - - if (! $projection) { + + if (!$projection) { return null; } return $projection->toArray(); } -} \ No newline at end of file +} diff --git a/src/CommandHandler/ListCustomersCommandHandler.php b/src/CommandHandler/ListCustomersCommandHandler.php index 446b489..b8f02fa 100644 --- a/src/CommandHandler/ListCustomersCommandHandler.php +++ b/src/CommandHandler/ListCustomersCommandHandler.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\ListCustomersCommand; @@ -10,11 +19,12 @@ final class ListCustomersCommandHandler { public function __construct( - private readonly CustomerProjectionRepository $projectionRepository - ) {} + private readonly CustomerProjectionRepository $projectionRepository, + ) { + } public function __invoke(ListCustomersCommand $command): array { return $this->projectionRepository->findAll(); } -} \ No newline at end of file +} diff --git a/src/CommandHandler/ListOrdersCommandHandler.php b/src/CommandHandler/ListOrdersCommandHandler.php index 580f502..5f87689 100644 --- a/src/CommandHandler/ListOrdersCommandHandler.php +++ b/src/CommandHandler/ListOrdersCommandHandler.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\ListOrdersCommand; @@ -10,11 +19,12 @@ final class ListOrdersCommandHandler { public function __construct( - private readonly OrderProjectionRepository $projectionRepository - ) {} + private readonly OrderProjectionRepository $projectionRepository, + ) { + } public function __invoke(ListOrdersCommand $command): array { return $this->projectionRepository->findAll(); } -} \ No newline at end of file +} diff --git a/src/CommandHandler/ListProductsCommandHandler.php b/src/CommandHandler/ListProductsCommandHandler.php index c61bccc..29df4c9 100644 --- a/src/CommandHandler/ListProductsCommandHandler.php +++ b/src/CommandHandler/ListProductsCommandHandler.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\ListProductsCommand; @@ -10,11 +19,12 @@ final class ListProductsCommandHandler { public function __construct( - private readonly ProductProjectionRepository $projectionRepository - ) {} + private readonly ProductProjectionRepository $projectionRepository, + ) { + } public function __invoke(ListProductsCommand $command): array { return $this->projectionRepository->findAll(); } -} \ No newline at end of file +} diff --git a/src/CommandHandler/UpdateProductCommandHandler.php b/src/CommandHandler/UpdateProductCommandHandler.php index d8a4cb8..8909aba 100644 --- a/src/CommandHandler/UpdateProductCommandHandler.php +++ b/src/CommandHandler/UpdateProductCommandHandler.php @@ -1,14 +1,23 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\CommandHandler; use App\Command\UpdateProductCommand; -use App\Repository\ProductRepository; use App\Projection\ProductProjectionService; +use App\Repository\ProductRepository; use App\Service\ProductSummaryProjectionService; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Psr\Log\LoggerInterface; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final readonly class UpdateProductCommandHandler @@ -18,8 +27,9 @@ public function __construct( private ProductProjectionService $productProjectionService, private ProductSummaryProjectionService $summaryProjectionService, private EntityManagerInterface $entityManager, - private LoggerInterface $logger - ) {} + private LoggerInterface $logger, + ) { + } public function __invoke(UpdateProductCommand $command): void { @@ -31,16 +41,16 @@ public function __invoke(UpdateProductCommand $command): void throw new \RuntimeException('Product not found'); } - if ($command->name !== null) { + if (null !== $command->name) { $product->setName($command->name); } - if ($command->description !== null) { + if (null !== $command->description) { $product->setDescription($command->description); } - if ($command->price !== null) { + if (null !== $command->price) { $product->setPrice($command->price); } - if ($command->createdAt !== null) { + if (null !== $command->createdAt) { $product->setCreatedAt($command->createdAt); } @@ -48,7 +58,7 @@ public function __invoke(UpdateProductCommand $command): void $this->entityManager->refresh($product); $this->productProjectionService->updateProjection($product); -// $this->summaryProjectionService->updateProductSummary($product->getId()); + // $this->summaryProjectionService->updateProductSummary($product->getId()); $this->entityManager->commit(); } catch (\Throwable $e) { @@ -60,4 +70,4 @@ public function __invoke(UpdateProductCommand $command): void throw $e; } } -} \ No newline at end of file +} diff --git a/src/Controller/CustomerController.php b/src/Controller/CustomerController.php index 81890af..e38836e 100644 --- a/src/Controller/CustomerController.php +++ b/src/Controller/CustomerController.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Controller; use App\Command\AddCustomerCommand; @@ -19,15 +28,17 @@ final class CustomerController extends AbstractController { public function __construct( private readonly MessageBusInterface $commandBus, - private readonly CustomerRepository $customerRepository - ) {} + private readonly CustomerRepository $customerRepository, + ) { + } #[Route('/db', name: 'list_customers_db', methods: ['GET'])] public function listCustomersDb(): JsonResponse { $customers = $this->customerRepository->findAll(); + return $this->json([ - 'customers' => array_map(function($c) { + 'customers' => array_map(static function ($c) { return [ 'id' => $c->getId(), 'name' => $c->getName(), @@ -36,10 +47,10 @@ public function listCustomersDb(): JsonResponse 'city' => $c->getCity(), 'postal_code' => $c->getPostalCode(), 'country' => $c->getCountry(), - 'created_at' => $c->getCreatedAt()?->format(DATE_ATOM), + 'created_at' => $c->getCreatedAt()?->format(\DATE_ATOM), ]; }, $customers), - 'total' => count($customers) + 'total' => \count($customers), ]); } @@ -48,9 +59,10 @@ public function listCustomersRedis(): JsonResponse { $envelope = $this->commandBus->dispatch(new ListCustomersCommand()); $projections = $envelope->last(HandledStamp::class)->getResult(); + return $this->json([ - 'customers' => array_map(fn($p) => $p->toArray(), $projections), - 'total' => count($projections) + 'customers' => array_map(static fn ($p) => $p->toArray(), $projections), + 'total' => \count($projections), ]); } @@ -67,6 +79,7 @@ public function addCustomer(Request $request): JsonResponse new \DateTimeImmutable($request->get('created_at') ?? 'now') ); $this->commandBus->dispatch($command); + return $this->json(['message' => 'Customer created successfully'], 201); } @@ -77,6 +90,7 @@ public function getCustomerDb(int $id): JsonResponse if (!$customer) { return $this->json(['error' => 'Customer not found'], 404); } + return $this->json([ 'customer' => [ 'id' => $customer->getId(), @@ -86,8 +100,8 @@ public function getCustomerDb(int $id): JsonResponse 'city' => $customer->getCity(), 'postal_code' => $customer->getPostalCode(), 'country' => $customer->getCountry(), - 'created_at' => $customer->getCreatedAt()?->format(DATE_ATOM), - ] + 'created_at' => $customer->getCreatedAt()?->format(\DATE_ATOM), + ], ]); } @@ -99,6 +113,7 @@ public function getCustomerRedis(int $id): JsonResponse if (!$customer) { return $this->json(['error' => 'Customer not found'], 404); } + return $this->json(['customer' => $customer]); } @@ -107,6 +122,7 @@ public function deleteCustomer(int $id): JsonResponse { $command = new DeleteCustomerCommand($id); $this->commandBus->dispatch($command); + return $this->json(['message' => 'Customer deleted successfully']); } -} \ No newline at end of file +} diff --git a/src/Controller/Metrics/FpmStatusController.php b/src/Controller/Metrics/FpmStatusController.php index ef9d2ff..acca479 100644 --- a/src/Controller/Metrics/FpmStatusController.php +++ b/src/Controller/Metrics/FpmStatusController.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Controller\Metrics; use Symfony\Component\HttpFoundation\JsonResponse; @@ -8,7 +17,6 @@ class FpmStatusController { - #[Route('/api/fpm-status', name: 'fpm_status', methods: ['GET'])] public function stats(): JsonResponse { @@ -19,25 +27,25 @@ public function stats(): JsonResponse $context = stream_context_create([ 'http' => [ 'timeout' => 5, - 'ignore_errors' => true - ] + 'ignore_errors' => true, + ], ]); $response = @file_get_contents($fpmUrl, false, $context); - if ($response === false) { + if (false === $response) { return new JsonResponse([ 'error' => 'Failed to fetch FPM status', - 'message' => 'Could not connect to FPM status endpoint' + 'message' => 'Could not connect to FPM status endpoint', ], 500); } $statusData = json_decode($response, true); - if (json_last_error() !== JSON_ERROR_NONE) { + if (\JSON_ERROR_NONE !== json_last_error()) { return new JsonResponse([ 'error' => 'Failed to parse FPM status', - 'message' => json_last_error_msg() + 'message' => json_last_error_msg(), ], 500); } @@ -45,7 +53,7 @@ public function stats(): JsonResponse } catch (\Exception $e) { return new JsonResponse([ 'error' => 'Failed to fetch FPM status', - 'message' => $e->getMessage() + 'message' => $e->getMessage(), ], 500); } } @@ -60,20 +68,20 @@ public function prometheusMetrics(): Response $context = stream_context_create([ 'http' => [ 'timeout' => 5, - 'ignore_errors' => true - ] + 'ignore_errors' => true, + ], ]); $response = @file_get_contents($fpmUrl, false, $context); - if ($response === false) { + if (false === $response) { throw new \Exception('Could not connect to FPM status endpoint'); } $status = json_decode($response, true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new \Exception('Failed to parse FPM status: ' . json_last_error_msg()); + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \Exception('Failed to parse FPM status: '.json_last_error_msg()); } $metrics = []; @@ -96,7 +104,7 @@ public function prometheusMetrics(): Response 'phpfpm_process_manager_type', 'PHP-FPM process manager type', 'gauge', - $status['process manager'] === 'dynamic' ? 1 : ($status['process manager'] === 'static' ? 2 : 0), + 'dynamic' === $status['process manager'] ? 1 : ('static' === $status['process manager'] ? 2 : 0), ['type' => $status['process manager'], 'pool' => $pool] ); } @@ -234,7 +242,7 @@ public function prometheusMetrics(): Response } // Calculate aggregated metrics from process data - if (isset($status['processes']) && is_array($status['processes'])) { + if (isset($status['processes']) && \is_array($status['processes'])) { $durations = []; $cpuValues = []; $memoryValues = []; @@ -253,7 +261,7 @@ public function prometheusMetrics(): Response // Average request duration (convert microseconds to milliseconds) if (!empty($durations)) { - $avgDuration = array_sum($durations) / count($durations) / 1000; + $avgDuration = array_sum($durations) / \count($durations) / 1000; $metrics[] = $this->formatMetric( 'phpfpm_request_duration_avg', 'Average request duration in milliseconds', @@ -274,7 +282,7 @@ public function prometheusMetrics(): Response // P95 request duration sort($durations); - $p95Index = (int) ceil(count($durations) * 0.95) - 1; + $p95Index = (int) ceil(\count($durations) * 0.95) - 1; $p95Duration = $durations[$p95Index] / 1000; $metrics[] = $this->formatMetric( 'phpfpm_request_duration_p95', @@ -285,7 +293,7 @@ public function prometheusMetrics(): Response ); // P99 request duration - $p99Index = (int) ceil(count($durations) * 0.99) - 1; + $p99Index = (int) ceil(\count($durations) * 0.99) - 1; $p99Duration = $durations[$p99Index] / 1000; $metrics[] = $this->formatMetric( 'phpfpm_request_duration_p99', @@ -298,7 +306,7 @@ public function prometheusMetrics(): Response // Average CPU if (!empty($cpuValues)) { - $avgCpu = array_sum($cpuValues) / count($cpuValues); + $avgCpu = array_sum($cpuValues) / \count($cpuValues); $metrics[] = $this->formatMetric( 'phpfpm_cpu_avg', 'Average CPU usage percentage', @@ -310,7 +318,7 @@ public function prometheusMetrics(): Response // Average memory if (!empty($memoryValues)) { - $avgMemory = array_sum($memoryValues) / count($memoryValues); + $avgMemory = array_sum($memoryValues) / \count($memoryValues); $metrics[] = $this->formatMetric( 'phpfpm_memory_avg', 'Average memory usage in bytes', @@ -322,18 +330,18 @@ public function prometheusMetrics(): Response } // Process details - if (isset($status['processes']) && is_array($status['processes'])) { + if (isset($status['processes']) && \is_array($status['processes'])) { foreach ($status['processes'] as $process) { $pid = $process['pid'] ?? 'unknown'; // Process state if (isset($process['state'])) { - $stateValue = match($process['state']) { + $stateValue = match ($process['state']) { 'Idle' => 0, 'Running' => 1, 'Reading headers' => 2, 'Finishing' => 3, - default => -1 + default => -1, }; $metrics[] = $this->formatMetric( @@ -391,12 +399,12 @@ public function prometheusMetrics(): Response } } - $content = implode("\n", $metrics) . "\n"; + $content = implode("\n", $metrics)."\n"; return new Response($content, 200, ['Content-Type' => 'text/plain; version=0.0.4']); } catch (\Exception $e) { $content = "# Failed to fetch FPM status\n"; - $content .= "# Error: " . $e->getMessage() . "\n"; + $content .= '# Error: '.$e->getMessage()."\n"; $content .= "# HELP phpfpm_up PHP-FPM status scrape success\n"; $content .= "# TYPE phpfpm_up gauge\n"; $content .= "phpfpm_up 0\n"; diff --git a/src/Controller/Metrics/OpcacheController.php b/src/Controller/Metrics/OpcacheController.php index 7b2eb92..2757633 100644 --- a/src/Controller/Metrics/OpcacheController.php +++ b/src/Controller/Metrics/OpcacheController.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Controller\Metrics; use Symfony\Component\HttpFoundation\JsonResponse; @@ -11,19 +20,21 @@ class OpcacheController #[Route('/opcache-stats', name: 'opcache_stats', methods: ['GET'])] public function stats(): JsonResponse { - if (function_exists('opcache_get_status')) { + if (\function_exists('opcache_get_status')) { $status = opcache_get_status(false); + return new JsonResponse($status); - } else { - return new JsonResponse(['error' => 'OPcache is not enabled or available.'], 500); } + + return new JsonResponse(['error' => 'OPcache is not enabled or available.'], 500); } #[Route('/metrics', name: 'prometheus_metrics', methods: ['GET'])] public function prometheusMetrics(): Response { - if (!function_exists('opcache_get_status')) { + if (!\function_exists('opcache_get_status')) { $content = "# OPcache is not enabled or available\n"; + return new Response($content, 200, ['Content-Type' => 'text/plain']); } @@ -31,11 +42,12 @@ public function prometheusMetrics(): Response $config = opcache_get_configuration(); // Check if OPcache is actually enabled and working - if ($status === false || $config === false) { + if (false === $status || false === $config) { $content = "# OPcache is not enabled or not working properly\n"; $content .= "# HELP opcache_enabled OPcache enabled status\n"; $content .= "# TYPE opcache_enabled gauge\n"; $content .= "opcache_enabled 0\n"; + return new Response($content, 200, ['Content-Type' => 'text/plain; version=0.0.4']); } @@ -74,7 +86,7 @@ public function prometheusMetrics(): Response ); // Memory usage metrics - if (isset($status['memory_usage']) && is_array($status['memory_usage'])) { + if (isset($status['memory_usage']) && \is_array($status['memory_usage'])) { $memory = $status['memory_usage']; if (isset($memory['used_memory'])) { @@ -115,7 +127,7 @@ public function prometheusMetrics(): Response } // Interned strings metrics - if (isset($status['interned_strings_usage']) && is_array($status['interned_strings_usage'])) { + if (isset($status['interned_strings_usage']) && \is_array($status['interned_strings_usage'])) { $strings = $status['interned_strings_usage']; if (isset($strings['buffer_size'])) { @@ -156,7 +168,7 @@ public function prometheusMetrics(): Response } // Statistics metrics - if (isset($status['opcache_statistics']) && is_array($status['opcache_statistics'])) { + if (isset($status['opcache_statistics']) && \is_array($status['opcache_statistics'])) { $stats = $status['opcache_statistics']; if (isset($stats['hits'])) { @@ -278,7 +290,7 @@ public function prometheusMetrics(): Response } // Preload statistics - if (isset($status['preload_statistics']) && is_array($status['preload_statistics'])) { + if (isset($status['preload_statistics']) && \is_array($status['preload_statistics'])) { $preload = $status['preload_statistics']; if (isset($preload['memory_consumption'])) { @@ -290,18 +302,18 @@ public function prometheusMetrics(): Response ); } - if (isset($preload['scripts']) && is_array($preload['scripts'])) { + if (isset($preload['scripts']) && \is_array($preload['scripts'])) { $metrics[] = $this->formatMetric( 'opcache_preload_scripts_count', 'Number of preloaded scripts', 'gauge', - count($preload['scripts']) + \count($preload['scripts']) ); } } // JIT statistics - if (isset($status['jit']) && is_array($status['jit'])) { + if (isset($status['jit']) && \is_array($status['jit'])) { $jit = $status['jit']; if (isset($jit['enabled'])) { @@ -369,7 +381,7 @@ public function prometheusMetrics(): Response } // Configuration metrics - if (isset($config['directives']) && is_array($config['directives'])) { + if (isset($config['directives']) && \is_array($config['directives'])) { $directives = $config['directives']; if (isset($directives['opcache.memory_consumption'])) { @@ -400,7 +412,7 @@ public function prometheusMetrics(): Response } } - $content = implode("\n", $metrics) . "\n"; + $content = implode("\n", $metrics)."\n"; return new Response($content, 200, ['Content-Type' => 'text/plain; version=0.0.4']); } @@ -414,4 +426,4 @@ private function formatMetric(string $name, string $help, string $type, $value): return implode("\n", $lines); } -} \ No newline at end of file +} diff --git a/src/Controller/OrderController.php b/src/Controller/OrderController.php index a7fa5ea..e0755e9 100644 --- a/src/Controller/OrderController.php +++ b/src/Controller/OrderController.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Controller; use App\Command\AddOrderCommand; @@ -19,15 +28,17 @@ final class OrderController extends AbstractController { public function __construct( private readonly MessageBusInterface $commandBus, - private readonly OrderRepository $orderRepository - ) {} + private readonly OrderRepository $orderRepository, + ) { + } #[Route('/db', name: 'list_orders_db', methods: ['GET'])] public function listOrdersDb(): JsonResponse { $orders = $this->orderRepository->findAll(); + return $this->json([ - 'orders' => array_map(function($o) { + 'orders' => array_map(static function ($o) { return [ 'id' => $o->getId(), 'customer_id' => $o->getCustomer()?->getId(), @@ -36,10 +47,10 @@ public function listOrdersDb(): JsonResponse 'total_amount' => $o->getTotalAmount(), 'status' => $o->getStatus(), 'items' => $o->getItems(), - 'created_at' => $o->getCreatedAt()?->format(DATE_ATOM), + 'created_at' => $o->getCreatedAt()?->format(\DATE_ATOM), ]; }, $orders), - 'total' => count($orders) + 'total' => \count($orders), ]); } @@ -48,9 +59,10 @@ public function listOrdersRedis(): JsonResponse { $envelope = $this->commandBus->dispatch(new ListOrdersCommand()); $projections = $envelope->last(HandledStamp::class)->getResult(); + return $this->json([ - 'orders' => array_map(fn($p) => $p->toArray(), $projections), - 'total' => count($projections) + 'orders' => array_map(static fn ($p) => $p->toArray(), $projections), + 'total' => \count($projections), ]); } @@ -68,6 +80,7 @@ public function addOrder(Request $request): JsonResponse isset($data['created_at']) ? new \DateTimeImmutable($data['created_at']) : new \DateTimeImmutable('now') ); $this->commandBus->dispatch($command); + return $this->json(['message' => 'Order created successfully'], 201); } @@ -78,6 +91,7 @@ public function getOrderDb(int $id): JsonResponse if (!$order) { return $this->json(['error' => 'Order not found'], 404); } + return $this->json([ 'order' => [ 'id' => $order->getId(), @@ -87,8 +101,8 @@ public function getOrderDb(int $id): JsonResponse 'total_amount' => $order->getTotalAmount(), 'status' => $order->getStatus(), 'items' => $order->getItems(), - 'created_at' => $order->getCreatedAt()?->format(DATE_ATOM), - ] + 'created_at' => $order->getCreatedAt()?->format(\DATE_ATOM), + ], ]); } @@ -100,6 +114,7 @@ public function getOrderRedis(int $id): JsonResponse if (!$order) { return $this->json(['error' => 'Order not found'], 404); } + return $this->json(['order' => $order]); } @@ -108,6 +123,7 @@ public function deleteOrder(int $id): JsonResponse { $command = new DeleteOrderCommand($id); $this->commandBus->dispatch($command); + return $this->json(['message' => 'Order deleted successfully']); } -} \ No newline at end of file +} diff --git a/src/Controller/ProductController.php b/src/Controller/ProductController.php index 7e2a511..e8b9aaa 100644 --- a/src/Controller/ProductController.php +++ b/src/Controller/ProductController.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Controller; use App\Command\AddProductCommand; @@ -7,21 +16,20 @@ use App\Command\GetProductCommand; use App\Command\ListProductsCommand; use App\Command\UpdateProductCommand; +use App\Repository\ProductRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\Stamp\HandledStamp; use Symfony\Component\Routing\Annotation\Route; -use App\Repository\ProductRepository; #[Route('/products')] final class ProductController extends AbstractController { public function __construct( private readonly MessageBusInterface $commandBus, - ) - { + ) { } #[Route('/projection', name: 'list_products_redis', methods: ['GET'])] @@ -29,9 +37,10 @@ public function listProductsRedis(): JsonResponse { $envelope = $this->commandBus->dispatch(new ListProductsCommand()); $projections = $envelope->last(HandledStamp::class)->getResult(); + return $this->json([ - 'products' => array_map(fn($product) => $product->toArray(), $projections), - 'total' => count($projections) + 'products' => array_map(static fn ($product) => $product->toArray(), $projections), + 'total' => \count($projections), ]); } @@ -46,6 +55,7 @@ public function addProduct(Request $request): JsonResponse new \DateTimeImmutable($data['created_at'] ?? 'now') ); $this->commandBus->dispatch($command); + return $this->json(['message' => 'Product created successfully'], 201); } @@ -57,6 +67,7 @@ public function getProductRedis(int $id): JsonResponse if (!$product) { return $this->json(['error' => 'Product not found'], 404); } + return $this->json(['product' => $product]); } @@ -65,6 +76,7 @@ public function deleteProduct(int $id): JsonResponse { $command = new DeleteProductCommand($id); $this->commandBus->dispatch($command); + return $this->json(['message' => 'Product deleted successfully']); } @@ -80,6 +92,7 @@ public function updateProduct(int $id, Request $request): JsonResponse isset($data['created_at']) ? new \DateTimeImmutable($data['created_at']) : null ); $this->commandBus->dispatch($command); + return $this->json(['message' => 'Product updated successfully']); } @@ -87,17 +100,18 @@ public function updateProduct(int $id, Request $request): JsonResponse public function listProductsDb(ProductRepository $productRepository): JsonResponse { $products = $productRepository->findAll(); + return $this->json([ - 'products' => array_map(function ($p) { + 'products' => array_map(static function ($p) { return [ 'id' => $p->getId(), 'name' => $p->getName(), 'description' => $p->getDescription(), 'price' => $p->getPrice(), - 'created_at' => $p->getCreatedAt()?->format(DATE_ATOM), + 'created_at' => $p->getCreatedAt()?->format(\DATE_ATOM), ]; }, $products), - 'total' => count($products) + 'total' => \count($products), ]); } @@ -108,14 +122,15 @@ public function getProductDb(int $id, ProductRepository $productRepository): Jso if (!$product) { return $this->json(['error' => 'Product not found'], 404); } + return $this->json([ 'product' => [ 'id' => $product->getId(), 'name' => $product->getName(), 'description' => $product->getDescription(), 'price' => $product->getPrice(), - 'created_at' => $product->getCreatedAt()?->format(DATE_ATOM), - ] + 'created_at' => $product->getCreatedAt()?->format(\DATE_ATOM), + ], ]); } -} \ No newline at end of file +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index f1a1e12..e77e0c9 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -26,7 +26,7 @@ final class AppFixtures extends Fixture { public function __construct( private readonly UserPasswordHasherInterface $passwordHasher, - private readonly SluggerInterface $slugger + private readonly SluggerInterface $slugger, ) { } @@ -80,7 +80,7 @@ private function loadPosts(ObjectManager $manager): void foreach (range(1, 5) as $i) { /** @var User $commentAuthor */ - $commentAuthor = $this->getReference('john_user'); + $commentAuthor = $this->getReference('john_user', User::class); $comment = new Comment(); $comment->setAuthor($commentAuthor); @@ -140,7 +140,7 @@ private function getPostData(): array // $postData = [$title, $slug, $summary, $content, $publishedAt, $author, $tags, $comments]; /** @var User $user */ - $user = $this->getReference(['jane_admin', 'tom_admin'][0 === $i ? 0 : random_int(0, 1)]); + $user = $this->getReference(['jane_admin', 'tom_admin'][0 === $i ? 0 : random_int(0, 1)], User::class); $posts[] = [ $title, @@ -262,7 +262,7 @@ private function getRandomTags(): array return array_map(function ($tagName) { /** @var Tag $tag */ - $tag = $this->getReference('tag-'.$tagName); + $tag = $this->getReference('tag-'.$tagName, Tag::class); return $tag; }, $selectedTags); diff --git a/src/DataFixtures/ProductFixtures.php b/src/DataFixtures/ProductFixtures.php index 8e998e6..8dde3b8 100644 --- a/src/DataFixtures/ProductFixtures.php +++ b/src/DataFixtures/ProductFixtures.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\DataFixtures; use App\Entity\Product; @@ -12,17 +21,17 @@ class ProductFixtures extends Fixture public function load(ObjectManager $manager): void { $faker = Factory::create(); - for ($i = 0; $i < 10000; $i++) { + for ($i = 0; $i < 10000; ++$i) { $product = new Product(); $product->setName($faker->words(3, true)); $product->setDescription($faker->sentence(12)); $product->setPrice($faker->randomFloat(2, 1, 1000)); $product->setCreatedAt(\DateTimeImmutable::createFromInterface($faker->dateTimeBetween('-2 years', 'now'))); $manager->persist($product); - if ($i % 1000 === 0) { + if (0 === $i % 1000) { $manager->flush(); } } $manager->flush(); } -} \ No newline at end of file +} diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index ae16403..e52a797 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Entity; use App\Repository\CustomerRepository; @@ -52,6 +61,7 @@ public function getName(): ?string public function setName(string $name): static { $this->name = $name; + return $this; } @@ -63,6 +73,7 @@ public function getEmail(): ?string public function setEmail(string $email): static { $this->email = $email; + return $this; } @@ -74,6 +85,7 @@ public function getAddress(): ?string public function setAddress(string $address): static { $this->address = $address; + return $this; } @@ -85,6 +97,7 @@ public function getCity(): ?string public function setCity(string $city): static { $this->city = $city; + return $this; } @@ -96,6 +109,7 @@ public function getPostalCode(): ?string public function setPostalCode(string $postalCode): static { $this->postalCode = $postalCode; + return $this; } @@ -107,6 +121,7 @@ public function getCountry(): ?string public function setCountry(string $country): static { $this->country = $country; + return $this; } @@ -118,6 +133,7 @@ public function getCreatedAt(): ?\DateTimeImmutable public function setCreatedAt(\DateTimeImmutable $createdAt): static { $this->createdAt = $createdAt; + return $this; } -} \ No newline at end of file +} diff --git a/src/Entity/Order.php b/src/Entity/Order.php index de00f4d..c2a1154 100644 --- a/src/Entity/Order.php +++ b/src/Entity/Order.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Entity; use App\Repository\OrderRepository; @@ -55,6 +64,7 @@ public function getCustomer(): ?Customer public function setCustomer(?Customer $customer): static { $this->customer = $customer; + return $this; } @@ -66,6 +76,7 @@ public function getOrderNumber(): ?string public function setOrderNumber(string $orderNumber): static { $this->orderNumber = $orderNumber; + return $this; } @@ -77,6 +88,7 @@ public function getTotalAmount(): ?string public function setTotalAmount(string $totalAmount): static { $this->totalAmount = $totalAmount; + return $this; } @@ -88,6 +100,7 @@ public function getStatus(): ?string public function setStatus(string $status): static { $this->status = $status; + return $this; } @@ -99,12 +112,14 @@ public function getItems(): array public function setItems(array $items): static { $this->items = $items; + return $this; } public function addItem(array $item): static { $this->items[] = $item; + return $this; } @@ -116,6 +131,7 @@ public function getCreatedAt(): ?\DateTimeImmutable public function setCreatedAt(\DateTimeImmutable $createdAt): static { $this->createdAt = $createdAt; + return $this; } @@ -127,6 +143,7 @@ public function getUpdatedAt(): ?\DateTimeImmutable public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static { $this->updatedAt = $updatedAt; + return $this; } -} \ No newline at end of file +} diff --git a/src/Entity/Product.php b/src/Entity/Product.php index 3eac391..b82c637 100644 --- a/src/Entity/Product.php +++ b/src/Entity/Product.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Entity; use Doctrine\ORM\Mapping as ORM; @@ -37,6 +46,7 @@ public function getName(): ?string public function setName(string $name): self { $this->name = $name; + return $this; } @@ -48,6 +58,7 @@ public function getDescription(): ?string public function setDescription(string $description): self { $this->description = $description; + return $this; } @@ -59,6 +70,7 @@ public function getPrice(): ?string public function setPrice(string $price): self { $this->price = $price; + return $this; } @@ -70,6 +82,7 @@ public function getCreatedAt(): ?\DateTimeImmutable public function setCreatedAt(\DateTimeImmutable $createdAt): self { $this->createdAt = $createdAt; + return $this; } -} \ No newline at end of file +} diff --git a/src/Event/CommentCreatedEvent.php b/src/Event/CommentCreatedEvent.php index f8d74cb..34f0924 100644 --- a/src/Event/CommentCreatedEvent.php +++ b/src/Event/CommentCreatedEvent.php @@ -17,7 +17,7 @@ final class CommentCreatedEvent extends Event { public function __construct( - protected Comment $comment + protected Comment $comment, ) { } diff --git a/src/EventSubscriber/CheckRequirementsSubscriber.php b/src/EventSubscriber/CheckRequirementsSubscriber.php index b8f728c..5227607 100644 --- a/src/EventSubscriber/CheckRequirementsSubscriber.php +++ b/src/EventSubscriber/CheckRequirementsSubscriber.php @@ -32,7 +32,7 @@ final readonly class CheckRequirementsSubscriber implements EventSubscriberInterface { public function __construct( - private EntityManagerInterface $entityManager + private EntityManagerInterface $entityManager, ) { } diff --git a/src/EventSubscriber/CommentNotificationSubscriber.php b/src/EventSubscriber/CommentNotificationSubscriber.php index b71327b..32c81b6 100644 --- a/src/EventSubscriber/CommentNotificationSubscriber.php +++ b/src/EventSubscriber/CommentNotificationSubscriber.php @@ -33,7 +33,7 @@ public function __construct( private UrlGeneratorInterface $urlGenerator, private TranslatorInterface $translator, #[Autowire('%app.notifications.email_sender%')] - private string $sender + private string $sender, ) { } diff --git a/src/EventSubscriber/ControllerSubscriber.php b/src/EventSubscriber/ControllerSubscriber.php index e85d62a..b0f8f9a 100644 --- a/src/EventSubscriber/ControllerSubscriber.php +++ b/src/EventSubscriber/ControllerSubscriber.php @@ -26,7 +26,7 @@ final readonly class ControllerSubscriber implements EventSubscriberInterface { public function __construct( - private SourceCodeExtension $twigExtension + private SourceCodeExtension $twigExtension, ) { } diff --git a/src/EventSubscriber/RedirectToPreferredLocaleSubscriber.php b/src/EventSubscriber/RedirectToPreferredLocaleSubscriber.php index d321a21..cda3ebe 100644 --- a/src/EventSubscriber/RedirectToPreferredLocaleSubscriber.php +++ b/src/EventSubscriber/RedirectToPreferredLocaleSubscriber.php @@ -32,7 +32,7 @@ public function __construct( private readonly UrlGeneratorInterface $urlGenerator, /** @var string[] */ private array $enabledLocales, - private ?string $defaultLocale = null + private ?string $defaultLocale = null, ) { if (empty($this->enabledLocales)) { throw new \UnexpectedValueException('The list of supported locales must not be empty.'); @@ -41,7 +41,7 @@ public function __construct( $this->defaultLocale = $defaultLocale ?: $this->enabledLocales[0]; if (!\in_array($this->defaultLocale, $this->enabledLocales, true)) { - throw new \UnexpectedValueException(sprintf('The default locale ("%s") must be one of "%s".', $this->defaultLocale, implode(', ', $this->enabledLocales))); + throw new \UnexpectedValueException(\sprintf('The default locale ("%s") must be one of "%s".', $this->defaultLocale, implode(', ', $this->enabledLocales))); } // Add the default locale at the first position of the array, diff --git a/src/Form/DataTransformer/TagArrayToStringTransformer.php b/src/Form/DataTransformer/TagArrayToStringTransformer.php index 0358ac0..28ee0b4 100644 --- a/src/Form/DataTransformer/TagArrayToStringTransformer.php +++ b/src/Form/DataTransformer/TagArrayToStringTransformer.php @@ -30,7 +30,7 @@ final readonly class TagArrayToStringTransformer implements DataTransformerInterface { public function __construct( - private TagRepository $tags + private TagRepository $tags, ) { } @@ -86,7 +86,7 @@ private function trim(array $strings): array $result = []; foreach ($strings as $string) { - $result[] = trim($string); + $result[] = mb_trim($string); } return $result; diff --git a/src/Form/PostType.php b/src/Form/PostType.php index 91b27da..8411a07 100644 --- a/src/Form/PostType.php +++ b/src/Form/PostType.php @@ -33,7 +33,7 @@ final class PostType extends AbstractType { // Form types are services, so you can inject other services in them if needed public function __construct( - private readonly SluggerInterface $slugger + private readonly SluggerInterface $slugger, ) { } diff --git a/src/Form/Type/TagsInputType.php b/src/Form/Type/TagsInputType.php index abd75e4..82ed632 100644 --- a/src/Form/Type/TagsInputType.php +++ b/src/Form/Type/TagsInputType.php @@ -31,7 +31,7 @@ final class TagsInputType extends AbstractType { public function __construct( - private readonly TagRepository $tags + private readonly TagRepository $tags, ) { } diff --git a/src/Pagination/Paginator.php b/src/Pagination/Paginator.php index f745bbd..af0a57a 100644 --- a/src/Pagination/Paginator.php +++ b/src/Pagination/Paginator.php @@ -38,7 +38,7 @@ final class Paginator public function __construct( private readonly DoctrineQueryBuilder $queryBuilder, - private readonly int $pageSize = self::PAGE_SIZE + private readonly int $pageSize = self::PAGE_SIZE, ) { } diff --git a/src/Projection/CustomerProjection.php b/src/Projection/CustomerProjection.php index 2cf4be6..82fe311 100644 --- a/src/Projection/CustomerProjection.php +++ b/src/Projection/CustomerProjection.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; readonly class CustomerProjection @@ -12,8 +21,9 @@ public function __construct( public string $city, public string $postalCode, public string $country, - public \DateTimeImmutable $createdAt - ) {} + public \DateTimeImmutable $createdAt, + ) { + } public function toArray(): array { @@ -28,4 +38,4 @@ public function toArray(): array 'created_at' => $this->createdAt->format('Y-m-d H:i:s'), ]; } -} \ No newline at end of file +} diff --git a/src/Projection/CustomerProjectionBuilder.php b/src/Projection/CustomerProjectionBuilder.php index c8b55be..8dc601b 100644 --- a/src/Projection/CustomerProjectionBuilder.php +++ b/src/Projection/CustomerProjectionBuilder.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use App\Entity\Customer; @@ -19,4 +28,4 @@ public function build(Customer $customer): CustomerProjection $customer->getCreatedAt() ); } -} \ No newline at end of file +} diff --git a/src/Projection/CustomerProjectionRepository.php b/src/Projection/CustomerProjectionRepository.php index 97e09d9..b442911 100644 --- a/src/Projection/CustomerProjectionRepository.php +++ b/src/Projection/CustomerProjectionRepository.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use Predis\ClientInterface; @@ -10,18 +19,20 @@ final class CustomerProjectionRepository private const REDIS_ALL_KEY = 'customers:all'; public function __construct( - private readonly ClientInterface $redis - ) {} + private readonly ClientInterface $redis, + ) { + } public function find(int $id): ?CustomerProjection { - $data = $this->redis->get(self::REDIS_KEY_PREFIX . $id); - + $data = $this->redis->get(self::REDIS_KEY_PREFIX.$id); + if (!$data) { return null; } $decoded = json_decode($data, true); + return $this->buildFromArray($decoded); } @@ -29,20 +40,20 @@ public function findAll(): array { // Get all IDs in one query $allIds = $this->redis->smembers(self::REDIS_ALL_KEY); - + if (empty($allIds)) { return []; } // Build all keys at once - $keys = array_map(fn($id) => self::REDIS_KEY_PREFIX . $id, $allIds); - + $keys = array_map(static fn ($id) => self::REDIS_KEY_PREFIX.$id, $allIds); + // Get all customers in ONE query using MGET $allData = $this->redis->mget($keys); - + $projections = []; foreach ($allData as $index => $data) { - if ($data !== null) { + if (null !== $data) { $decoded = json_decode($data, true); if ($decoded) { $projections[] = $this->buildFromArray($decoded); @@ -56,25 +67,25 @@ public function findAll(): array public function save(CustomerProjection $projection): void { $data = json_encode($projection->toArray()); - $this->redis->set(self::REDIS_KEY_PREFIX . $projection->id, $data); + $this->redis->set(self::REDIS_KEY_PREFIX.$projection->id, $data); $this->redis->sadd(self::REDIS_ALL_KEY, $projection->id); } public function delete(int $id): void { - $this->redis->del(self::REDIS_KEY_PREFIX . $id); + $this->redis->del(self::REDIS_KEY_PREFIX.$id); $this->redis->srem(self::REDIS_ALL_KEY, $id); } public function clear(): void { $allIds = $this->redis->smembers(self::REDIS_ALL_KEY); - + if (!empty($allIds)) { - $keys = array_map(fn($id) => self::REDIS_KEY_PREFIX . $id, $allIds); + $keys = array_map(static fn ($id) => self::REDIS_KEY_PREFIX.$id, $allIds); $this->redis->del($keys); } - + $this->redis->del(self::REDIS_ALL_KEY); } @@ -91,4 +102,4 @@ private function buildFromArray(array $data): CustomerProjection new \DateTimeImmutable($data['created_at']) ); } -} \ No newline at end of file +} diff --git a/src/Projection/CustomerProjectionService.php b/src/Projection/CustomerProjectionService.php index 8484e9e..cca312e 100644 --- a/src/Projection/CustomerProjectionService.php +++ b/src/Projection/CustomerProjectionService.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use App\Entity\Customer; @@ -10,15 +19,16 @@ final class CustomerProjectionService public function __construct( private readonly CustomerRepository $customerRepository, private readonly CustomerProjectionRepository $projectionRepository, - private readonly CustomerProjectionBuilder $projectionBuilder - ) {} + private readonly CustomerProjectionBuilder $projectionBuilder, + ) { + } public function rebuildAll(): void { $this->projectionRepository->clear(); - + $customers = $this->customerRepository->findAll(); - + foreach ($customers as $customer) { $projection = $this->projectionBuilder->build($customer); $this->projectionRepository->save($projection); @@ -35,4 +45,4 @@ public function deleteProjection(int $id): void { $this->projectionRepository->delete($id); } -} \ No newline at end of file +} diff --git a/src/Projection/OrderProjection.php b/src/Projection/OrderProjection.php index bd3d509..a8ab2e8 100644 --- a/src/Projection/OrderProjection.php +++ b/src/Projection/OrderProjection.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; readonly class OrderProjection @@ -13,8 +22,9 @@ public function __construct( public string $status, public array $items, public \DateTimeImmutable $createdAt, - public ?\DateTimeImmutable $updatedAt = null - ) {} + public ?\DateTimeImmutable $updatedAt = null, + ) { + } public function getId(): int { @@ -75,4 +85,4 @@ public function toArray(): array 'updated_at' => $this->updatedAt?->format('Y-m-d H:i:s'), ]; } -} \ No newline at end of file +} diff --git a/src/Projection/OrderProjectionBuilder.php b/src/Projection/OrderProjectionBuilder.php index 5a5ae56..c582f4a 100644 --- a/src/Projection/OrderProjectionBuilder.php +++ b/src/Projection/OrderProjectionBuilder.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use App\Entity\Order; @@ -20,4 +29,4 @@ public function build(Order $order): OrderProjection $order->getUpdatedAt() ); } -} \ No newline at end of file +} diff --git a/src/Projection/OrderProjectionRepository.php b/src/Projection/OrderProjectionRepository.php index 4e2745f..3792ba3 100644 --- a/src/Projection/OrderProjectionRepository.php +++ b/src/Projection/OrderProjectionRepository.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use Predis\ClientInterface; @@ -11,18 +20,20 @@ final class OrderProjectionRepository private const REDIS_CUSTOMER_KEY = 'orders:customer:'; public function __construct( - private readonly ClientInterface $redis - ) {} + private readonly ClientInterface $redis, + ) { + } public function find(int $id): ?OrderProjection { - $data = $this->redis->get(self::REDIS_KEY_PREFIX . $id); - + $data = $this->redis->get(self::REDIS_KEY_PREFIX.$id); + if (!$data) { return null; } $decoded = json_decode($data, true); + return $this->buildFromArray($decoded); } @@ -30,20 +41,20 @@ public function findAll(): array { // Get all IDs in one query $allIds = $this->redis->smembers(self::REDIS_ALL_KEY); - + if (empty($allIds)) { return []; } // Build all keys at once - $keys = array_map(fn($id) => self::REDIS_KEY_PREFIX . $id, $allIds); - + $keys = array_map(static fn ($id) => self::REDIS_KEY_PREFIX.$id, $allIds); + // Get all orders in ONE query using MGET $allData = $this->redis->mget($keys); - + $projections = []; foreach ($allData as $index => $data) { - if ($data !== null) { + if (null !== $data) { $decoded = json_decode($data, true); if ($decoded) { $projections[] = $this->buildFromArray($decoded); @@ -57,21 +68,21 @@ public function findAll(): array public function findByCustomer(int $customerId): array { // Get all order IDs for this customer in one query - $orderIds = $this->redis->smembers(self::REDIS_CUSTOMER_KEY . $customerId); - + $orderIds = $this->redis->smembers(self::REDIS_CUSTOMER_KEY.$customerId); + if (empty($orderIds)) { return []; } // Build all keys at once - $keys = array_map(fn($id) => self::REDIS_KEY_PREFIX . $id, $orderIds); - + $keys = array_map(static fn ($id) => self::REDIS_KEY_PREFIX.$id, $orderIds); + // Get all orders in ONE query using MGET $allData = $this->redis->mget($keys); - + $projections = []; foreach ($allData as $index => $data) { - if ($data !== null) { + if (null !== $data) { $decoded = json_decode($data, true); if ($decoded) { $projections[] = $this->buildFromArray($decoded); @@ -85,34 +96,34 @@ public function findByCustomer(int $customerId): array public function save(OrderProjection $projection): void { $data = json_encode($projection->toArray()); - $this->redis->set(self::REDIS_KEY_PREFIX . $projection->getId(), $data); + $this->redis->set(self::REDIS_KEY_PREFIX.$projection->getId(), $data); $this->redis->sadd(self::REDIS_ALL_KEY, $projection->getId()); - $this->redis->sadd(self::REDIS_CUSTOMER_KEY . $projection->getCustomerId(), $projection->getId()); + $this->redis->sadd(self::REDIS_CUSTOMER_KEY.$projection->getCustomerId(), $projection->getId()); } public function delete(int $id): void { $projection = $this->find($id); if ($projection) { - $this->redis->del(self::REDIS_KEY_PREFIX . $id); + $this->redis->del(self::REDIS_KEY_PREFIX.$id); $this->redis->srem(self::REDIS_ALL_KEY, $id); - $this->redis->srem(self::REDIS_CUSTOMER_KEY . $projection->getCustomerId(), $id); + $this->redis->srem(self::REDIS_CUSTOMER_KEY.$projection->getCustomerId(), $id); } } public function clear(): void { $allIds = $this->redis->smembers(self::REDIS_ALL_KEY); - + if (!empty($allIds)) { - $keys = array_map(fn($id) => self::REDIS_KEY_PREFIX . $id, $allIds); + $keys = array_map(static fn ($id) => self::REDIS_KEY_PREFIX.$id, $allIds); $this->redis->del($keys); } - + $this->redis->del(self::REDIS_ALL_KEY); - + // Clear customer-specific sets - $customerKeys = $this->redis->keys(self::REDIS_CUSTOMER_KEY . '*'); + $customerKeys = $this->redis->keys(self::REDIS_CUSTOMER_KEY.'*'); if (!empty($customerKeys)) { $this->redis->del($customerKeys); } @@ -132,4 +143,4 @@ private function buildFromArray(array $data): OrderProjection isset($data['updated_at']) ? new \DateTimeImmutable($data['updated_at']) : null ); } -} \ No newline at end of file +} diff --git a/src/Projection/OrderProjectionService.php b/src/Projection/OrderProjectionService.php index ed11d3c..75ef8da 100644 --- a/src/Projection/OrderProjectionService.php +++ b/src/Projection/OrderProjectionService.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use App\Entity\Order; @@ -10,15 +19,16 @@ final class OrderProjectionService public function __construct( private readonly OrderRepository $orderRepository, private readonly OrderProjectionRepository $projectionRepository, - private readonly OrderProjectionBuilder $projectionBuilder - ) {} + private readonly OrderProjectionBuilder $projectionBuilder, + ) { + } public function rebuildAll(): void { $this->projectionRepository->clear(); - + $orders = $this->orderRepository->findAll(); - + foreach ($orders as $order) { $projection = $this->projectionBuilder->build($order); $this->projectionRepository->save($projection); @@ -35,4 +45,4 @@ public function deleteProjection(int $id): void { $this->projectionRepository->delete($id); } -} \ No newline at end of file +} diff --git a/src/Projection/ProductProjection.php b/src/Projection/ProductProjection.php index c20d41a..4e1845a 100644 --- a/src/Projection/ProductProjection.php +++ b/src/Projection/ProductProjection.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; readonly class ProductProjection @@ -9,8 +18,9 @@ public function __construct( public string $name, public string $description, public float $price, - public \DateTimeImmutable $createdAt - ) {} + public \DateTimeImmutable $createdAt, + ) { + } public function toArray(): array { @@ -22,4 +32,4 @@ public function toArray(): array 'created_at' => $this->createdAt->format('Y-m-d H:i:s'), ]; } -} \ No newline at end of file +} diff --git a/src/Projection/ProductProjectionBuilder.php b/src/Projection/ProductProjectionBuilder.php index 963fbcd..020b0d5 100644 --- a/src/Projection/ProductProjectionBuilder.php +++ b/src/Projection/ProductProjectionBuilder.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use App\Entity\Product; @@ -16,4 +25,4 @@ public function build(Product $product): ProductProjection $product->getCreatedAt() ); } -} \ No newline at end of file +} diff --git a/src/Projection/ProductProjectionRepository.php b/src/Projection/ProductProjectionRepository.php index 60e33c5..b14bc78 100644 --- a/src/Projection/ProductProjectionRepository.php +++ b/src/Projection/ProductProjectionRepository.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use Predis\ClientInterface; @@ -10,12 +19,13 @@ final class ProductProjectionRepository private const REDIS_ALL_KEY = 'products:all'; public function __construct( - private readonly ClientInterface $redis - ) {} + private readonly ClientInterface $redis, + ) { + } public function find(int $id): ?ProductProjection { - $data = $this->redis->get(self::REDIS_KEY_PREFIX . $id); + $data = $this->redis->get(self::REDIS_KEY_PREFIX.$id); if (!$data) { return null; @@ -23,6 +33,7 @@ public function find(int $id): ?ProductProjection return unserialize($data); } + public function findAll(): array { $allIds = $this->redis->smembers(self::REDIS_ALL_KEY); @@ -31,13 +42,13 @@ public function findAll(): array return []; } - $keys = array_map(fn($id) => self::REDIS_KEY_PREFIX . $id, $allIds); + $keys = array_map(static fn ($id) => self::REDIS_KEY_PREFIX.$id, $allIds); $allData = $this->redis->mget($keys); $projections = []; foreach ($allData as $data) { - if ($data !== null) { + if (null !== $data) { $projections[] = unserialize($data); } } @@ -47,13 +58,13 @@ public function findAll(): array public function save(ProductProjection $projection): void { - $this->redis->set(self::REDIS_KEY_PREFIX . $projection->id, serialize($projection)); + $this->redis->set(self::REDIS_KEY_PREFIX.$projection->id, serialize($projection)); $this->redis->sadd(self::REDIS_ALL_KEY, [$projection->id]); } public function delete(int $id): void { - $this->redis->del(self::REDIS_KEY_PREFIX . $id); + $this->redis->del(self::REDIS_KEY_PREFIX.$id); $this->redis->srem(self::REDIS_ALL_KEY, $id); } @@ -62,10 +73,10 @@ public function clear(): void $allIds = $this->redis->smembers(self::REDIS_ALL_KEY); if (!empty($allIds)) { - $keys = array_map(fn($id) => self::REDIS_KEY_PREFIX . $id, $allIds); + $keys = array_map(static fn ($id) => self::REDIS_KEY_PREFIX.$id, $allIds); $this->redis->del($keys); } $this->redis->del(self::REDIS_ALL_KEY); } -} \ No newline at end of file +} diff --git a/src/Projection/ProductProjectionService.php b/src/Projection/ProductProjectionService.php index c491209..55cdfee 100644 --- a/src/Projection/ProductProjectionService.php +++ b/src/Projection/ProductProjectionService.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use App\Entity\Product; @@ -8,11 +17,10 @@ final readonly class ProductProjectionService { public function __construct( - private ProductRepository $productRepository, + private ProductRepository $productRepository, private ProductProjectionRepository $projectionRepository, - private ProductProjectionBuilder $projectionBuilder - ) - { + private ProductProjectionBuilder $projectionBuilder, + ) { } public function rebuildAll(): void @@ -37,4 +45,4 @@ public function deleteProjection(int $id): void { $this->projectionRepository->delete($id); } -} \ No newline at end of file +} diff --git a/src/Projection/ProductSummaryProjection.php b/src/Projection/ProductSummaryProjection.php index a3dd9db..6005e23 100644 --- a/src/Projection/ProductSummaryProjection.php +++ b/src/Projection/ProductSummaryProjection.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; final readonly class ProductSummaryProjection @@ -9,8 +18,9 @@ public function __construct( public string $name, public string $description, public string $price, - public \DateTimeImmutable $createdAt - ) {} + public \DateTimeImmutable $createdAt, + ) { + } public function toArray(): array { @@ -33,4 +43,4 @@ public static function fromArray(array $data): self new \DateTimeImmutable($data['created_at']) ); } -} \ No newline at end of file +} diff --git a/src/Projection/ProductSummaryProjectionBuilder.php b/src/Projection/ProductSummaryProjectionBuilder.php index 4e23db5..f153c5b 100644 --- a/src/Projection/ProductSummaryProjectionBuilder.php +++ b/src/Projection/ProductSummaryProjectionBuilder.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use Doctrine\DBAL\Connection; @@ -7,8 +16,9 @@ final readonly class ProductSummaryProjectionBuilder { public function __construct( - private Connection $connection - ) {} + private Connection $connection, + ) { + } public function buildForProduct(int $productId): ?ProductSummaryProjection { @@ -16,6 +26,7 @@ public function buildForProduct(int $productId): ?ProductSummaryProjection if (!$data) { return null; } + return new ProductSummaryProjection( productId: (int) $data['id'], name: $data['name'], @@ -32,7 +43,8 @@ public function getAllProductIds(): array private function getProductSummaryData(int $productId): ?array { - $sql = "SELECT id, name, description, price, created_at FROM product WHERE id = :productId"; + $sql = 'SELECT id, name, description, price, created_at FROM product WHERE id = :productId'; + return $this->connection->fetchAssociative($sql, ['productId' => $productId]) ?: null; } -} \ No newline at end of file +} diff --git a/src/Projection/ProductSummaryProjectionRepository.php b/src/Projection/ProductSummaryProjectionRepository.php index ba164fc..f9e7c39 100644 --- a/src/Projection/ProductSummaryProjectionRepository.php +++ b/src/Projection/ProductSummaryProjectionRepository.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Projection; use Predis\Client as Redis; @@ -10,12 +19,13 @@ final class ProductSummaryProjectionRepository private const KEY_ALL = 'product_summaries:all'; public function __construct( - private Redis $redis - ) {} + private Redis $redis, + ) { + } public function save(ProductSummaryProjection $projection): void { - $key = self::KEY_PREFIX . $projection->productId; + $key = self::KEY_PREFIX.$projection->productId; $this->redis->hmset($key, $projection->toArray()); $this->redis->expire($key, 3600); $this->redis->sadd(self::KEY_ALL, [$projection->productId]); @@ -23,11 +33,12 @@ public function save(ProductSummaryProjection $projection): void public function findById(int $productId): ?ProductSummaryProjection { - $key = self::KEY_PREFIX . $productId; + $key = self::KEY_PREFIX.$productId; $data = $this->redis->hgetall($key); if (!$data || empty($data['product_id'])) { return null; } + return ProductSummaryProjection::fromArray($data); } @@ -35,21 +46,21 @@ public function findAll(): array { $productIds = $this->redis->smembers(self::KEY_ALL); $projections = []; - + foreach ($productIds as $productId) { $projection = $this->findById((int) $productId); if ($projection) { $projections[] = $projection; } } - + return $projections; } public function remove(int $productId): void { - $key = self::KEY_PREFIX . $productId; + $key = self::KEY_PREFIX.$productId; $this->redis->del($key); $this->redis->srem(self::KEY_ALL, $productId); } -} \ No newline at end of file +} diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php index 67fbe97..e9e643f 100644 --- a/src/Repository/CustomerRepository.php +++ b/src/Repository/CustomerRepository.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Repository; use App\Entity\Customer; @@ -38,4 +47,4 @@ public function remove(Customer $entity, bool $flush = false): void $this->getEntityManager()->flush(); } } -} \ No newline at end of file +} diff --git a/src/Repository/OrderRepository.php b/src/Repository/OrderRepository.php index 7751bfe..5a39645 100644 --- a/src/Repository/OrderRepository.php +++ b/src/Repository/OrderRepository.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Repository; use App\Entity\Order; @@ -38,4 +47,4 @@ public function remove(Order $entity, bool $flush = false): void $this->getEntityManager()->flush(); } } -} \ No newline at end of file +} diff --git a/src/Repository/ProductRepository.php b/src/Repository/ProductRepository.php index d52d3d8..d6a4c5b 100644 --- a/src/Repository/ProductRepository.php +++ b/src/Repository/ProductRepository.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Repository; use App\Entity\Product; @@ -12,4 +21,4 @@ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Product::class); } -} \ No newline at end of file +} diff --git a/src/Service/ProductSummaryProjectionService.php b/src/Service/ProductSummaryProjectionService.php index 2e9a96a..ae376c8 100644 --- a/src/Service/ProductSummaryProjectionService.php +++ b/src/Service/ProductSummaryProjectionService.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace App\Service; use App\Projection\ProductSummaryProjectionBuilder; @@ -11,8 +20,9 @@ public function __construct( private ProductSummaryProjectionBuilder $builder, private ProductSummaryProjectionRepository $repository, - private LoggerInterface $logger - ) {} + private LoggerInterface $logger, + ) { + } public function updateProductSummary(int $productId): void { @@ -22,7 +32,7 @@ public function updateProductSummary(int $productId): void $this->repository->save($projection); $this->logger->info('Product projection updated', [ 'product_id' => $productId, - 'projection_type' => 'ProductSummary' + 'projection_type' => 'ProductSummary', ]); } } catch (\Throwable $e) { @@ -40,4 +50,4 @@ public function rebuildAllProjections(): void $this->updateProductSummary($productId); } } -} \ No newline at end of file +} diff --git a/src/Twig/SourceCodeExtension.php b/src/Twig/SourceCodeExtension.php index 182cf99..bcc5e8f 100644 --- a/src/Twig/SourceCodeExtension.php +++ b/src/Twig/SourceCodeExtension.php @@ -71,7 +71,7 @@ public function linkSourceFile(Environment $twig, string $file, int $line): stri return ''; } - return sprintf('%s at line %d', + return \sprintf('%s at line %d', htmlspecialchars($link, \ENT_COMPAT | \ENT_SUBSTITUTE, $twig->getCharset()), htmlspecialchars($text, \ENT_COMPAT | \ENT_SUBSTITUTE, $twig->getCharset()), $line, @@ -102,14 +102,14 @@ private function getController(): ?array $fileName = $method->getFileName(); if (false === $classCode = file($fileName)) { - throw new \LogicException(sprintf('There was an error while trying to read the contents of the "%s" file.', $fileName)); + throw new \LogicException(\sprintf('There was an error while trying to read the contents of the "%s" file.', $fileName)); } $startLine = $method->getStartLine() - 1; $endLine = $method->getEndLine(); while ($startLine > 0) { - $line = trim($classCode[$startLine - 1]); + $line = mb_trim($classCode[$startLine - 1]); if (\in_array($line, ['{', '}', ''], true)) { break; diff --git a/tests/Command/AddUserCommandTest.php b/tests/Command/AddUserCommandTest.php index 240c249..a3d7c31 100644 --- a/tests/Command/AddUserCommandTest.php +++ b/tests/Command/AddUserCommandTest.php @@ -76,7 +76,7 @@ public function testCreateUserInteractive(bool $isAdmin): void * This is used to execute the same test twice: first for normal users * (isAdmin = false) and then for admin users (isAdmin = true). */ - public function isAdminDataProvider(): \Generator + public static function isAdminDataProvider(): \Generator { yield [false]; yield [true]; diff --git a/tests/Command/ListUsersCommandTest.php b/tests/Command/ListUsersCommandTest.php index 78fb509..c042f3c 100644 --- a/tests/Command/ListUsersCommandTest.php +++ b/tests/Command/ListUsersCommandTest.php @@ -30,7 +30,7 @@ public function testListUsers(int $maxResults): void $this->assertSame($emptyDisplayLines + $maxResults, mb_substr_count($tester->getDisplay(), "\n")); } - public function maxResultsProvider(): \Generator + public static function maxResultsProvider(): \Generator { yield [1]; yield [2]; diff --git a/tests/Controller/Admin/BlogControllerTest.php b/tests/Controller/Admin/BlogControllerTest.php index 7f3618d..a08ef88 100644 --- a/tests/Controller/Admin/BlogControllerTest.php +++ b/tests/Controller/Admin/BlogControllerTest.php @@ -67,7 +67,7 @@ public function testAccessDeniedForRegularUsers(string $httpMethod, string $url) $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); } - public function getUrlsForRegularUsers(): \Generator + public static function getUrlsForRegularUsers(): \Generator { yield ['GET', '/en/admin/post/']; yield ['GET', '/en/admin/post/1']; diff --git a/tests/Controller/DefaultControllerTest.php b/tests/Controller/DefaultControllerTest.php index 804832f..5195f27 100644 --- a/tests/Controller/DefaultControllerTest.php +++ b/tests/Controller/DefaultControllerTest.php @@ -40,7 +40,7 @@ public function testPublicUrls(string $url): void $client = static::createClient(); $client->request('GET', $url); - $this->assertResponseIsSuccessful(sprintf('The %s public URL loads correctly.', $url)); + $this->assertResponseIsSuccessful(\sprintf('The %s public URL loads correctly.', $url)); } /** @@ -61,7 +61,7 @@ public function testPublicBlogPost(): void /** @var Post $blogPost */ $blogPost = $registry->getRepository(Post::class)->find(1); - $client->request('GET', sprintf('/en/blog/posts/%s', $blogPost->getSlug())); + $client->request('GET', \sprintf('/en/blog/posts/%s', $blogPost->getSlug())); $this->assertResponseIsSuccessful(); } @@ -80,18 +80,18 @@ public function testSecureUrls(string $url): void $this->assertResponseRedirects( 'http://localhost/en/login', Response::HTTP_FOUND, - sprintf('The %s secure URL redirects to the login form.', $url) + \sprintf('The %s secure URL redirects to the login form.', $url) ); } - public function getPublicUrls(): \Generator + public static function getPublicUrls(): \Generator { yield ['/']; yield ['/en/blog/']; yield ['/en/login']; } - public function getSecureUrls(): \Generator + public static function getSecureUrls(): \Generator { yield ['/en/admin/post/']; yield ['/en/admin/post/new']; diff --git a/tests/Controller/UserControllerTest.php b/tests/Controller/UserControllerTest.php index d65d905..d218638 100644 --- a/tests/Controller/UserControllerTest.php +++ b/tests/Controller/UserControllerTest.php @@ -44,11 +44,11 @@ public function testAccessDeniedForAnonymousUsers(string $httpMethod, string $ur $this->assertResponseRedirects( 'http://localhost/en/login', Response::HTTP_FOUND, - sprintf('The %s secure URL redirects to the login form.', $url) + \sprintf('The %s secure URL redirects to the login form.', $url) ); } - public function getUrlsForAnonymousUsers(): \Generator + public static function getUrlsForAnonymousUsers(): \Generator { yield ['GET', '/en/profile/edit']; yield ['GET', '/en/profile/change-password'];