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
-
-
-
-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
-
-
-
-```
-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
-```
-
-Go to http://localhost:3000/, choose Dashboard in the sidebar and click `PHP-FPM Performance Dashboard`
-
-
-
-### 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:
-
-
-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:**
-
-
-
-**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:
-
-
-
-### 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. ✅
+
+
+> 📸 *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).
+
+
+> 📸 *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`.
+
+
+> 📸 *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.
+
+
+> 📸 *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.
+
+
+> 📸 *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. 💓
+
+
+> 📸 *Capture: the dashboard while the wave is busy (RPS/CPU high). Save as `docs/images/ember-06-live.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.
+
+
+> 📸 *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):
+
+
+
+**Certificates** - the TLS certs Caddy is managing, with expiry days:
+
+
+
+**Logs** - Caddy's Runtime log (server startup / errors):
+
+
+
+---
+
+## 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.)
+
+
+> 📸 *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'];